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:
- Internal focus - Primary consumers are .NET clients and internal tooling
- Debugging simplicity - JSON output directly matches C# model property names
- Validation testing - Easier to verify request/response equality (exact string match)
- 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:
- C# reference types can be
null(especially with nullable DTOs likeNestedTestObject?) - Client code generators (NSwag, OpenAPI Generator) need to know a property can be
null - The built-in
Microsoft.AspNetCore.OpenApipackage doesn't add nullability information to$refproperties
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/anyOfwith{ "type": "null" }for reference typestypeas 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
$refproperties 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 configurationapi/AtomicUI.Api/Extensions/OpenApiServiceExtensions.cs- OpenAPI schema transformersapi/AtomicUI.Api/Extensions/OpenApiApplicationExtensions.cs- Custom OpenAPI endpoint with $ref nullable transformationapi/AtomicUI.Api/Models/ConfigurationEnums.cs- Enum definitions with[JsonConverter]api/AtomicUI.Api/Models/TestConfigurationRequest.cs- Model with nullable typesapi/AtomicUI.Api/Validators/TestConfigurationRequestValidator.cs- FluentValidation rulestools/UtilitiesClient/Program.cs- Test client with matching JSON configuration
π¬ Comments & Reactions