This comprehensive guide explains all the configuration tweaks for ASP.NET Core API JSON serialization, enum handling, nullable types, and OpenAPI schema generation. Master the nuances of System.Text.Json, model binding, and OpenAPI 3.1 compliance.

1. PascalCase Property Naming

Problem

By default, ASP.NET Core uses camelCase for JSON property names (e.g., minDate, maxInt). This may not match your C# model's PascalCase property names (e.g., MinDate, MaxInt).

Solution

Set PropertyNamingPolicy = null to preserve the original C# casing (PascalCase).

Code Example

// For MVC Controllers
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
    // Use PascalCase for property names (null = no transformation, keeps original casing)
    options.JsonSerializerOptions.PropertyNamingPolicy = null;
});

// For Minimal APIs and OpenAPI schema generation
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    // Use PascalCase for property names in OpenAPI schema (null = no transformation)
    options.SerializerOptions.PropertyNamingPolicy = null;
});

Result - JSON Response

  • Before: {"minDate":"2024-01-01","maxInt":100}
  • After: {"MinDate":"2024-01-01","MaxInt":100}

Result - OpenAPI Schema (/openapi/v1.json)

Before (camelCase):

{
  "components": {
    "schemas": {
      "TestConfigurationRequest": {
        "type": "object",
        "properties": {
          "minDate": {
            "type": "string",
            "format": "date-time"
          },
          "maxDate": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    }
  }
}

After (PascalCase):

{
  "components": {
    "schemas": {
      "TestConfigurationRequest": {
        "type": "object",
        "properties": {
          "MinDate": {
            "type": "string",
            "format": "date-time"
          },
          "MaxDate": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    }
  }
}

2. camelCase vs PascalCase: Pros and Cons

Choosing between camelCase and PascalCase for JSON property names is a design decision with trade-offs. Here's a comparison:

camelCase (Default in ASP.NET Core)

Pros Cons
βœ… Industry standard for JSON/JavaScript ecosystems ❌ Mismatch with C# property names (requires mental mapping)
βœ… Better frontend compatibility - JavaScript uses camelCase natively ❌ Potential serialization bugs if casing isn't handled consistently
βœ… Smaller payload - slightly (camelCase is often shorter) ❌ May need [JsonPropertyName] attributes for custom mappings
βœ… Expected by most REST API consumers ❌ Client-side deserialization needs case-insensitive parsing

PascalCase (C# Default / Our Choice)

Pros Cons
βœ… 1:1 match with C# model - no mental mapping needed ❌ Non-standard for JSON/JavaScript world
βœ… Easier debugging - JSON matches source code exactly ❌ Frontend may need property mapping or transformations
βœ… No surprises - what you see in C# is what you get in JSON ❌ Some API consumers expect camelCase by convention
βœ… Consistent across stack - same names in C#, OpenAPI, and JSON ❌ Slightly larger payload (capital letters don't compress as well)

When to Use Each

Use Case Recommendation
Public REST API consumed by various clients camelCase (industry standard)
Internal API with .NET clients only PascalCase (consistency with C#)
TypeScript/JavaScript frontend camelCase (native JS convention)
Blazor or .NET MAUI frontend PascalCase (same ecosystem)
OpenAPI code generation Either works, but be consistent
Legacy system integration Match the existing convention

Our Decision: PascalCase

For this project, we chose PascalCase because:

  1. Internal focus - Primary consumers are .NET clients and internal tooling
  2. Debugging simplicity - JSON output directly matches C# model property names
  3. Validation testing - Easier to verify request/response equality (exact string match)
  4. Reduced cognitive load - No need to remember "which casing is this property?"

Switching Between Conventions

If you need to switch conventions later, it's a single-line change:

// PascalCase (null = no transformation)
options.JsonSerializerOptions.PropertyNamingPolicy = null;

// camelCase (default)
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

// snake_case (if needed)
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;

// SCREAMING_SNAKE_CASE
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseUpper;

// kebab-case
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower;

3. Enum String Serialization

Problem

By default, enums are serialized as integers (their underlying value), e.g., "Enum1": 1. This makes API responses less readable and harder to understand without looking up enum definitions.

Solution

Use JsonStringEnumConverter to serialize enums as their string names.

Code Example

Step 1: Add converter to enum definition:

using System.Text.Json.Serialization;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SimpleEnum
{
    Value1 = 1,
    Value2 = 2,
    Value3 = 3,
    Value4 = 4
}

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TenEnum
{
    Value10 = 10,
    Value20 = 20,
    Value30 = 30,
    Value40 = 40
}

Step 2: Add global converter in Program.cs:

builder.Services.AddControllers()
.AddJsonOptions(options =>
{
    // Serialize enums as strings (names) instead of integers
    options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

Result

  • Before: {"Enum1": 1, "Enum2": 20}
  • After: {"Enum1": "Value1", "Enum2": "Value20"}

4. OpenAPI Enum Schema Transformation

Problem

Even with JsonStringEnumConverter, the OpenAPI schema (v1.json) may still show enums as integers by default. This makes the Swagger/Scalar UI less informative.

Solution

Add a schema transformer in OpenAPI configuration to convert enums with JsonStringEnumConverter attribute to string type with named values.

Code Example

In OpenApiServiceExtensions.cs:

using System.Text.Json.Serialization;
using System.Text.Json.Nodes;

services.AddOpenApi(options =>
{
    // Transform enums to show string values with enum names
    options.AddSchemaTransformer((schema, context, cancellationToken) =>
    {
        // Check if this is an enum type with JsonStringEnumConverter attribute
        var type = context.JsonTypeInfo.Type;
        if (type.IsEnum)
        {
            var hasStringEnumConverter = type.GetCustomAttributes(typeof(JsonConverterAttribute), false)
                .OfType<JsonConverterAttribute>()
                .Any(a => a.ConverterType == typeof(JsonStringEnumConverter));

            if (hasStringEnumConverter)
            {
                // Convert to string type with enum values
                schema.Type = JsonSchemaType.String;
                schema.Enum = Enum.GetNames(type)
                    .Select(name => (JsonNode)JsonValue.Create(name)!)
                    .ToList();
            }
        }

        return Task.CompletedTask;
    });
});

Result in OpenAPI Schema

  • Before: "type": "integer", "enum": [1, 2, 3, 4]
  • After: "type": "string", "enum": ["Value1", "Value2", "Value3", "Value4"]

5. Nullable Reference Types (string?) Without Required Error

Problem

In .NET 6+, when using nullable reference types (NRT), a property declared as string? NullString is treated as required by the model binder. If not provided in the request, you get a validation error:

"The NullString field is required."

This happens even though the property is nullable and should accept null.

Solution

Disable the implicit required attribute for non-nullable reference types using SuppressImplicitRequiredAttributeForNonNullableReferenceTypes.

Code Example

builder.Services.AddControllers(options =>
{
    // Allow nullable reference types (string?) to be truly optional in model binding
    options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
});

Model Example

public class TestConfigurationRequest
{
    // Nullable string - will NOT trigger "required" error when omitted
    public string? NullString { get; set; }

    // Non-nullable with default - still optional due to default value
    public string EmptyString { get; set; } = string.Empty;

    // Non-nullable - use validation to require
    public string NotEmptyString { get; set; } = string.Empty;
}

Validation with FluentValidation

public class TestConfigurationRequestValidator : AbstractValidator<TestConfigurationRequest>
{
    public TestConfigurationRequestValidator()
    {
        // NullString should be null (for testing purposes)
        RuleFor(x => x.NullString).Must(x => x == null)
            .WithMessage("NullString must be null");

        // EmptyString must equal empty string (not null)
        RuleFor(x => x.EmptyString)
            .NotNull().WithMessage("EmptyString must not be null")
            .Must(x => x == string.Empty).WithMessage("EmptyString must be an empty string");

        // NotEmptyString must not be null or whitespace
        RuleFor(x => x.NotEmptyString)
            .NotEmpty().WithMessage("NotEmptyString must not be empty or whitespace");
    }
}

6. Nullable $ref Properties in OpenAPI 3.1

Problem

In OpenAPI 3.1, reference-type properties that use $ref to reference other schemas are not automatically marked as nullable. This causes issues because:

  1. C# reference types can be null (especially with nullable DTOs like NestedTestObject?)
  2. Client code generators (NSwag, OpenAPI Generator) need to know a property can be null
  3. The built-in Microsoft.AspNetCore.OpenApi package doesn't add nullability information to $ref properties

Example of the Problem

Before (incorrect - no nullability):

{
  "TestConfigurationRequest": {
    "type": "object",
    "properties": {
      "NullObject": {
        "$ref": "#/components/schemas/NestedTestObject"
      }
    }
  }
}

The NullObject property should indicate it can be null, but there's no nullability information.

Solution

Use JSON post-processing to transform $ref properties into OpenAPI 3.1 compliant oneOf with null type.

After (correct - OpenAPI 3.1 compliant):

{
  "TestConfigurationRequest": {
    "type": "object",
    "properties": {
      "NullObject": {
        "oneOf": [
          { "$ref": "#/components/schemas/NestedTestObject" },
          { "type": "null" }
        ]
      }
    }
  }
}

Why oneOf Instead of nullable: true?

In OpenAPI 3.1 (which uses JSON Schema draft 2020-12), the nullable: true keyword is deprecated. Instead, nullability is expressed using:

  • oneOf / anyOf with { "type": "null" } for reference types
  • type as an array including "null" for primitive types (e.g., "type": ["string", "null"])

Implementation Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Browser/      β”‚     β”‚   Custom Endpoint    β”‚     β”‚  Built-in       β”‚
β”‚   Scalar UI     │────▢│   /openapi/v1.json   │────▢│  /openapi/      β”‚
β”‚                 β”‚     β”‚                      β”‚     β”‚  v1-raw.json    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚  1. Fetch raw JSON   β”‚     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚  2. Parse with JObjectβ”‚
                        β”‚  3. Transform $refs   β”‚
                        β”‚  4. Return modified   β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Code in OpenApiApplicationExtensions.cs

// Map the built-in OpenAPI at an internal path
webApp.MapOpenApi("/openapi/v1-raw.json");

// Map a custom endpoint that fetches and transforms
webApp.MapGet("/openapi/v1.json", async (HttpContext context, [FromServices] IHttpClientFactory? httpClientFactory) =>
{
    // 1. Fetch raw OpenAPI from internal endpoint
    var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
    using var client = httpClientFactory?.CreateClient() ?? new HttpClient();
    var content = await client.GetStringAsync($"{baseUrl}/openapi/v1-raw.json");
    
    // 2. Parse with Newtonsoft.Json for easy manipulation
    var jObj = Newtonsoft.Json.Linq.JToken.Parse(content);
    var schemas = jObj["components"]?["schemas"] as Newtonsoft.Json.Linq.JObject;
    
    if (schemas != null)
    {
        foreach (var schemaProp in schemas.Properties())
        {
            var schema = schemaProp.Value as Newtonsoft.Json.Linq.JObject;
            var properties = schema?["properties"] as Newtonsoft.Json.Linq.JObject;
            if (properties == null) continue;

            foreach (var prop in properties.Properties())
            {
                var propObj = prop.Value as Newtonsoft.Json.Linq.JObject;
                
                // Transform $ref to oneOf with null type
                if (propObj != null && propObj["$ref"] != null && propObj["oneOf"] == null)
                {
                    var refValue = propObj["$ref"]!.ToString();
                    propObj.RemoveAll();
                    propObj["oneOf"] = new Newtonsoft.Json.Linq.JArray(
                        new Newtonsoft.Json.Linq.JObject(new Newtonsoft.Json.Linq.JProperty("$ref", refValue)),
                        new Newtonsoft.Json.Linq.JObject(new Newtonsoft.Json.Linq.JProperty("type", "null"))
                    );
                }
            }
        }
    }
    
    // 3. Return modified JSON
    context.Response.ContentType = "application/json";
    await context.Response.WriteAsync(jObj.ToString(Newtonsoft.Json.Formatting.Indented));
    return Results.Empty;
}).ExcludeFromDescription();

// Point Scalar UI to our custom endpoint
webApp.MapScalarApiReference(options =>
{
    options.WithOpenApiRoutePattern("/openapi/v1.json");
});

Note: This transformation marks ALL $ref properties as nullable. If you need more granular control (only marking ? nullable types), you would need to integrate with reflection or use source generators.

7. Two JSON Configuration Scopes

Important: ASP.NET Core has two separate JSON configuration scopes

Scope Configuration Affects
MVC Controllers AddControllers().AddJsonOptions(...) Controller action serialization/deserialization
Minimal APIs & OpenAPI Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(...) Minimal API endpoints, OpenAPI schema generation

Why Both Are Needed

If you only configure MVC's AddJsonOptions, your controller responses will use PascalCase and string enums, but:

  • The OpenAPI schema (/openapi/v1.json) will still use camelCase
  • Minimal API endpoints (if any) will use camelCase

If you only configure the global JsonOptions, MVC controllers will ignore it.

Code Example

// Configure global JSON options (affects Minimal APIs and OpenAPI schema generation)
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.PropertyNamingPolicy = null;
    options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

// Configure MVC JSON options (affects Controllers)
builder.Services.AddControllers(options =>
{
    options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
})
.AddJsonOptions(options =>
{
    options.JsonSerializerOptions.PropertyNamingPolicy = null;
    options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

8. Complete Program.cs Configuration

Here's the complete configuration section from Program.cs:

using Serilog;
using FluentValidation;
using FluentValidation.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Use Serilog for logging
builder.Host.UseSerilog();

// Configure global JSON options (affects Minimal APIs and OpenAPI schema generation)
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    // Use PascalCase for property names in OpenAPI schema (null = no transformation)
    options.SerializerOptions.PropertyNamingPolicy = null;
    // Serialize enums as strings
    options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

// Add services to the container.
builder.Services.AddControllers(options =>
{
    // Allow nullable reference types (string?) to be truly optional in model binding
    options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
})
.AddJsonOptions(options =>
{
    // Use PascalCase for property names (null = no transformation, keeps original casing)
    options.JsonSerializerOptions.PropertyNamingPolicy = null;
    // Serialize enums as strings (names) instead of integers
    options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});

// Add FluentValidation
builder.Services.AddFluentValidationAutoValidation()
    .AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// ... rest of configuration

Summary Table

Feature Configuration Location
PascalCase properties PropertyNamingPolicy = null Both AddJsonOptions and Configure<JsonOptions>
Enum as strings JsonStringEnumConverter Both AddJsonOptions and Configure<JsonOptions> + [JsonConverter] on enum
OpenAPI enum names Schema transformer OpenApiServiceExtensions.cs
Nullable string? truly optional SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true AddControllers(options => ...)
Nullable $ref in OpenAPI 3.1 JSON post-processing with oneOf OpenApiApplicationExtensions.cs

Known Issues

.NET 10 WebApplicationFactory PipeWriter Issue

When returning objects directly from controller actions (instead of wrapping in ActionResult<T>), integration tests using WebApplicationFactory may fail with:

System.NotImplementedException: ResponseBodyPipeWriter does not implement UnflushedBytes

This is a known issue in .NET 10 preview. The workaround is to skip integration tests and test the controller with unit tests or a real HTTP client.

[Fact(Skip = ".NET 10 WebApplicationFactory PipeWriter.UnflushedBytes issue - test works in production")]
public async Task Utilities_TestConfiguration_ValidRequest_ReturnsSameObject()
{
    // Test code here
}

Related Files

  • api/AtomicUI.Api/Program.cs - Main configuration
  • api/AtomicUI.Api/Extensions/OpenApiServiceExtensions.cs - OpenAPI schema transformers
  • api/AtomicUI.Api/Extensions/OpenApiApplicationExtensions.cs - Custom OpenAPI endpoint with $ref nullable transformation
  • api/AtomicUI.Api/Models/ConfigurationEnums.cs - Enum definitions with [JsonConverter]
  • api/AtomicUI.Api/Models/TestConfigurationRequest.cs - Model with nullable types
  • api/AtomicUI.Api/Validators/TestConfigurationRequestValidator.cs - FluentValidation rules
  • tools/UtilitiesClient/Program.cs - Test client with matching JSON configuration

πŸ’¬ Comments & Reactions