Zentient Results Api Reference Serialization - ulfbou/Zentient.Results GitHub Wiki

📤 Serialization


Serializing Result and Result<T> types is crucial for scenarios like building APIs (returning JSON responses), logging, inter-service communication (e.g., message queues), and persistence. This section outlines how Zentient.Results types behave with common JSON serializers, specifically focusing on System.Text.Json.

JSON Serialization

Zentient.Results types (Result, Result<T>, ErrorInfo, ResultStatus) are designed to be largely compatible with standard JSON serializers due to their immutable nature and use of public properties.

System.Text.Json (Recommended for .NET Core/.NET 5+)

System.Text.Json is the default JSON serializer in modern .NET applications.

Serializing Concrete Result and Result<T>

When you directly use the concrete Result or Result<T> structs, System.Text.Json can usually serialize them without special configuration. The properties (IsSuccess, IsFailure, Errors, Messages, Status, Value) will be serialized as standard JSON fields.

Key considerations:

  • Value: For Result<T>, if IsFailure is true, Value will be default(T) (e.g., null for reference types, 0 for int). System.Text.Json will serialize this as null or 0 respectively.
  • Errors and Messages: These collections will be serialized as JSON arrays.
  • ErrorInfo and ResultStatus: These are also readonly struct types with public properties, so they serialize cleanly.

Example: Serializing Result<User>

using Zentient.Results;
using System.Text.Json;
using System.Text.Json.Serialization; // For optional attributes
using System.Collections.Generic;

public record User(int Id, string Name, string Email);

// --- Successful Result ---
IResult<User> successResult = Result.Success(
    new User(1, "Alice", "[email protected]"),
    "User loaded successfully."
);

string jsonSuccess = JsonSerializer.Serialize(successResult, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine("--- Successful Result JSON ---");
Console.WriteLine(jsonSuccess);

/* Expected Output:
--- Successful Result JSON ---
{
  "Value": {
    "Id": 1,
    "Name": "Alice",
    "Email": "[email protected]"
  },
  "IsSuccess": true,
  "IsFailure": false,
  "Errors": [],
  "Messages": [
    "User loaded successfully."
  ],
  "Error": null,
  "Status": {
    "Code": 200,
    "Description": "OK",
    "IsSuccess": true
  }
}
*/

// --- Failed Result ---
IResult<User> failureResult = Result.NotFound<User>("User", "999");

string jsonFailure = JsonSerializer.Serialize(failureResult, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine("\n--- Failed Result JSON ---");
Console.WriteLine(jsonFailure);

/* Expected Output:
--- Failed Result JSON ---
{
  "Value": null, // 'Value' is null for reference type T when failed
  "IsSuccess": false,
  "IsFailure": true,
  "Errors": [
    {
      "Category": 4, // Corresponds to ErrorCategory.NotFound enum value
      "Code": "UserNotFound",
      "Message": "Resource 'User' with identifier '999' not found.",
      "Parameters": {
        "resourceName": "User",
        "identifier": "999"
      }
    }
  ],
  "Messages": [],
  "Error": "Resource 'User' with identifier '999' not found.",
  "Status": {
    "Code": 404,
    "Description": "Not Found",
    "IsSuccess": false
  }
}
*/

Deserializing Concrete Result and Result<T>

Deserialization also works well for concrete Result and Result<T> types. System.Text.Json can correctly map the JSON properties back to the struct's fields.

Example: Deserializing Result<User>

using Zentient.Results;
using System.Text.Json;
using System.Collections.Generic;

public record User(int Id, string Name, string Email);

// --- Deserializing Successful Result ---
string jsonSuccess = @"{
  ""Value"": { ""Id"": 1, ""Name"": ""Alice"", ""Email"": ""[email protected]"" },
  ""IsSuccess"": true,
  ""IsFailure"": false,
  ""Errors"": [],
  ""Messages"": [""User loaded successfully.""],
  ""Error"": null,
  ""Status"": { ""Code"": 200, ""Description"": ""OK"", ""IsSuccess"": true }
}";

IResult<User>? deserializedSuccess = JsonSerializer.Deserialize<Result<User>>(jsonSuccess);

Console.WriteLine("\n--- Deserialized Successful Result ---");
Console.WriteLine($"IsSuccess: {deserializedSuccess?.IsSuccess}");
Console.WriteLine($"Value: {deserializedSuccess?.Value?.Name}");
Console.WriteLine($"Status: {deserializedSuccess?.Status?.Description}");

// --- Deserializing Failed Result ---
string jsonFailure = @"{
  ""Value"": null,
  ""IsSuccess"": false,
  ""IsFailure"": true,
  ""Errors"": [
    {
      ""Category"": 4,
      ""Code"": ""UserNotFound"",
      ""Message"": ""Resource 'User' with identifier '999' not found."",
      ""Parameters"": { ""resourceName"": ""User"", ""identifier"": ""999"" }
    }
  ],
  ""Messages"": [],
  ""Error"": ""Resource 'User' with identifier '999' not found."",
  ""Status"": { ""Code"": 404, ""Description"": ""Not Found"", ""IsSuccess"": false }
}";

IResult<User>? deserializedFailure = JsonSerializer.Deserialize<Result<User>>(jsonFailure);

Console.WriteLine("\n--- Deserialized Failed Result ---");
Console.WriteLine($"IsSuccess: {deserializedFailure?.IsSuccess}");
Console.WriteLine($"Error: {deserializedFailure?.Error}");
Console.WriteLine($"Status: {deserializedFailure?.Status?.Description}");
Console.WriteLine($"Error Category: {deserializedFailure?.Errors.FirstOrDefault()?.Category}");

Important: Serializing/Deserializing Interfaces (IResult, IResult<T>)

Directly serializing or deserializing interfaces like IResult or IResult<T> with System.Text.Json requires additional configuration due to its emphasis on strict type handling.

By default, System.Text.Json cannot deserialize an interface directly because it doesn't know which concrete type to instantiate. When serializing an interface, it typically only serializes the public properties defined on the interface itself.

To handle polymorphism (e.g., if your API controller returns IResult<User> and you want to deserialize it back as Result<User>), you have two primary options:

  1. Use JsonDerivedType (for .NET 7+): This attribute on the base interface allows you to specify derived types for polymorphic deserialization. This is the cleanest approach for modern .NET.

    // In your project, you'd add this to a shared IResult interface or a custom IResult interface you define.
    // However, for Zentient.Results' built-in IResult, you cannot directly add attributes.
    // If you *must* deserialize to an interface type, you'd need a custom JsonConverter.
    
    // Example of how JsonDerivedType *would* work if you controlled the IResult definition:
    /*
    [JsonDerivedType(typeof(Result), typeDiscriminator: "nonGenericResult")]
    [JsonDerivedType(typeof(Result<>), typeDiscriminator: "genericResult")] // This syntax doesn't quite work for open generics like Result<T>
    public interface IResult { /* ... */ }
    */
    
    // For Zentient.Results, it's generally best to:
    // a) Return/Expect concrete Result/Result<T> types in API contracts.
    // b) Use a custom JsonConverter for IResult/IResult<T> if you insist on using interfaces in contracts.
    // c) Use Newtonsoft.Json if you need simpler polymorphic handling out-of-the-box (see next section).
  2. Implement a Custom JsonConverter<T>: For complex scenarios or if you need to deserialize to interface types where JsonDerivedType isn't feasible (like Zentient.Results' built-in interfaces), you can write a custom JsonConverter that handles the logic of determining the concrete type (e.g., based on IsSuccess or IsFailure properties) during deserialization. This is more involved.

    // This is an example of a simple custom converter.
    // A robust converter would need to handle all properties of Result and Result<T> correctly,
    // including generic types. This is non-trivial for Result<T>.
    /*
    public class IResultConverter : JsonConverter<IResult>
    {
        public override IResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            // Read the JSON object
            using (JsonDocument doc = JsonDocument.ParseValue(ref reader))
            {
                var root = doc.RootElement;
                if (root.TryGetProperty("IsSuccess", out JsonElement isSuccessElement) && isSuccessElement.GetBoolean())
                {
                    // It's a success result. Need to determine if it's generic.
                    // This is where it gets complex for a single converter for both IResult and IResult<T>.
                    // A more practical approach might be separate converters or using concrete types.
                    // For simplicity, let's assume non-generic for this example:
                    return JsonSerializer.Deserialize<Result>(root.GetRawText(), options);
                }
                else
                {
                    // It's a failure result
                    return JsonSerializer.Deserialize<Result>(root.GetRawText(), options);
                }
            }
        }
    
        public override void Write(Utf8JsonWriter writer, IResult value, JsonSerializerOptions options)
        {
            // Default serialization works for the concrete type
            JsonSerializer.Serialize(writer, (object)value, options);
        }
    }
    
    // Usage:
    // var options = new JsonSerializerOptions();
    // options.Converters.Add(new IResultConverter());
    // IResult? deserializedResult = JsonSerializer.Deserialize<IResult>(jsonString, options);
    */

Recommendation for System.Text.Json: For most scenarios, it's simpler and more robust to serialize and deserialize directly using the concrete Result<T> or Result types in your API contracts (e.g., public Result<User> GetUser(int id)). If you absolutely need to use the IResult interface type in your API contract, consider returning a ProblemDetails (RFC 7807) for failures and the direct value for success if the framework allows, or implementing a robust custom JsonConverter (which is outside the scope of simple API reference).

Newtonsoft.Json

Newtonsoft.Json (Json.NET) often provides more lenient out-of-the-box support for polymorphic serialization/deserialization, particularly when using TypeNameHandling.Objects ($type property).

  • Polymorphism: If you configure TypeNameHandling.Objects, Newtonsoft.Json can embed type information ($type property) that allows it to deserialize interfaces back to their concrete types. This comes with security implications, so use with caution and validation for untrusted inputs.
  • Structs: Generally handles readonly struct types well.

Example with Newtonsoft.Json:

/*
using Newtonsoft.Json;

// --- Successful Result ---
IResult<User> successResult = Result.Success(
    new User(1, "Alice", "[email protected]"),
    "User loaded successfully."
);

string jsonSuccessNewtonsoft = JsonConvert.SerializeObject(successResult, Formatting.Indented);
Console.WriteLine("\n--- Successful Result JSON (Newtonsoft) ---");
Console.WriteLine(jsonSuccessNewtonsoft);

// --- Deserializing with TypeNameHandling (Newtonsoft) ---
// Note: This option has security implications and should be used carefully.
var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.Objects,
    Formatting = Formatting.Indented
};

string jsonSuccessWithTypeName = JsonConvert.SerializeObject(successResult, settings);
Console.WriteLine("\n--- Success Result JSON (Newtonsoft with TypeNameHandling) ---");
Console.WriteLine(jsonSuccessWithTypeName);

// Deserializing back to IResult<User>
IResult<User>? deserializedSuccessNewtonsoft = JsonConvert.DeserializeObject<IResult<User>>(jsonSuccessWithTypeName, settings);
Console.WriteLine("\n--- Deserialized Successful Result (Newtonsoft to IResult<User>) ---");
Console.WriteLine($"IsSuccess: {deserializedSuccessNewtonsoft?.IsSuccess}");
Console.WriteLine($"Value: {deserializedSuccessNewtonsoft?.Value?.Name}");
// Ensure to have the Zentient.Results.Result<T> type visible to the deserializer

*/

Custom Serialization / Deserialization

While default serialization handles most cases, you might require custom logic for:

  • Omitting properties: For example, always omitting the Value property if IsFailure is true, even if it's default(T).
  • Specific formatting: Changing how ErrorInfo or ResultStatus are represented (e.g., flattening them).
  • Optimized payload: Creating a more compact JSON representation for network efficiency.

To achieve this, you would implement a custom JsonConverter<T> for the Result or Result<T> types. This involves overriding the Read and Write methods to control the serialization and deserialization process manually. However, due to the generic nature and internal structure of Result<T>, writing a comprehensive custom converter can be complex.

For most use cases, relying on the default serialization of the concrete Result and Result<T> structs is sufficient and recommended.

⚠️ **GitHub.com Fallback** ⚠️