In modern .NET cloud engineering—spanning ASP.NET Core web APIs, microservices, Azure Functions, Blazor, and .NET MAUI applications—exchanging structured data via JSON is a continuous requirement. Translating dynamic JSON payloads into strongly typed C# classes and records ensures compile-time safety, seamless IDE autocompletion, robust validation, and maximum runtime throughput.
Since .NET Core 3.0 and through modern .NET 8 and .NET 9, Microsoft provides System.Text.Json as the primary, high-performance, zero-allocation JSON engine. At the same time, millions of existing codebases and specialized libraries rely on Newtonsoft.Json (Json.NET).
This comprehensive guide details the mechanics of converting JSON to C# models: from choosing between class and C# 9+ record, handling nullable reference types and PascalCase property mapping, to mastering polymorphic serialization, custom converters, and Native AOT source generation.
1. Choosing Between C# class and record for JSON DTOs
C# offers two primary reference type abstractions for modeling JSON data: traditional mutable class declarations and modern immutable record definitions.
#nullable enable
using System.Text.Json.Serialization;
// 1. Traditional Mutable Class (get; set;)
public class UserAccountClass
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
}
// 2. Immutable Record (get; init;) — Recommended for API DTOs
public record UserAccountRecord
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("username")]
public string Username { get; init; } = string.Empty;
}
Key Differences Comparison
| Feature | Mutable class (get; set;) |
Immutable record (get; init;) |
|---|---|---|
| Immutability | Properties can be altered anytime after instantiation. | Properties are locked once object initialization finishes. |
| Equality Semantics | Reference equality (a == b checks memory pointer). |
Value equality (a == b compares all property values). |
| Non-Destructive Mutation | Requires manual cloning or constructor instantiation. | Supported natively via the with expression: record with { Id = 2 }. |
| Primary Use Case | Domain entities with active lifecycle mutations. | Data Transfer Objects (DTOs), API requests, event payloads. |
2. Serialization Attributes: System.Text.Json vs Newtonsoft.Json
Because .NET conventions enforce PascalCase for public property names while JSON APIs commonly use snake_case or camelCase, serialization attributes map names without violating framework conventions.
System.Text.Json (using System.Text.Json.Serialization;)
Uses [JsonPropertyName("raw_json_key")]:
using System.Text.Json.Serialization;
public class MetricReport
{
[JsonPropertyName("host_name")]
public string HostName { get; set; } = string.Empty;
[JsonPropertyName("cpu_utilization_pct")]
public double CpuUtilizationPct { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("debug_trace")]
public string? DebugTrace { get; set; }
}
Newtonsoft.Json (using Newtonsoft.Json;)
Uses [JsonProperty("raw_json_key")]:
using Newtonsoft.Json;
public class MetricReport
{
[JsonProperty("host_name")]
public string HostName { get; set; }
[JsonProperty("cpu_utilization_pct")]
public double CpuUtilizationPct { get; set; }
[JsonProperty("debug_trace", NullValueHandling = NullValueHandling.Ignore)]
public string DebugTrace { get; set; }
}
3. Mastering Nullable Reference Types & Value Nullability
Starting with C# 8.0, .NET introduced Nullable Reference Types (#nullable enable). This allows the Roslyn compiler to distinguish between non-nullable reference types (string, List<T>, custom class) and nullable reference types (string?, List<T>?).
Distinguishing Missing vs Explicit Null Values
#nullable enable
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class CustomerProfile
{
// Mandatory string: Deserializer must populate this; null raises compiler warning
[JsonPropertyName("customer_id")]
public string CustomerId { get; set; } = string.Empty;
// Optional string: Explicitly nullable (can be null in JSON)
[JsonPropertyName("nickname")]
public string? Nickname { get; set; }
// Value Type: bool cannot be null. Use bool? to allow null or omitted values
[JsonPropertyName("opt_in_newsletter")]
public bool? OptInNewsletter { get; set; }
// Collection: Non-null list initialized to empty collection to prevent NullReferenceException
[JsonPropertyName("tags")]
public List<string> Tags { get; set; } = new();
}
4. Modeling Nested Objects and Collections
Complex JSON payloads contain nested sub-objects and lists. Instead of placing entire schemas inside a single unreadable structure, standard .NET architecture decomposes each object into a standalone class:
#nullable enable
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class OrderEnvelope
{
[JsonPropertyName("order_id")]
public string OrderId { get; set; } = string.Empty;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("customer")]
public OrderCustomer Customer { get; set; } = new();
[JsonPropertyName("items")]
public List<OrderItem> Items { get; set; } = new();
}
public class OrderCustomer
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
}
public class OrderItem
{
[JsonPropertyName("sku")]
public string Sku { get; set; } = string.Empty;
[JsonPropertyName("quantity")]
public int Quantity { get; set; }
[JsonPropertyName("unit_price")]
public double UnitPrice { get; set; }
}
5. Polymorphic Deserialization in .NET 7, 8 & 9
When consuming polymorphic JSON (such as webhook events or plugin configurations with a type discriminator), System.Text.Json supports native polymorphic hierarchy handling via attributes:
#nullable enable
using System.Text.Json.Serialization;
[JsonPolymorphic(TypeDiscriminatorPropertyName = "event_type")]
[JsonDerivedType(typeof(PaymentSucceededEvent), typeDiscriminator: "payment_succeeded")]
[JsonDerivedType(typeof(PaymentFailedEvent), typeDiscriminator: "payment_failed")]
public abstract record WebhookEvent
{
[JsonPropertyName("event_id")]
public string EventId { get; init; } = string.Empty;
[JsonPropertyName("timestamp")]
public long Timestamp { get; init; }
}
public record PaymentSucceededEvent : WebhookEvent
{
[JsonPropertyName("amount_cents")]
public long AmountCents { get; init; }
[JsonPropertyName("transaction_id")]
public string TransactionId { get; init; } = string.Empty;
}
public record PaymentFailedEvent : WebhookEvent
{
[JsonPropertyName("error_code")]
public string ErrorCode { get; init; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; init; } = string.Empty;
}
6. Custom JsonConverter<T> for Non-Standard Formats
If an API returns Unix epoch seconds as a number instead of an ISO-8601 string, a custom converter handles the conversion cleanly:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
public class UnixEpochDateTimeConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
{
long seconds = reader.GetInt64();
return DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
}
if (reader.TokenType == JsonTokenType.String && DateTime.TryParse(reader.GetString(), out var date))
{
return date;
}
throw new JsonException($"Unable to parse {reader.TokenType} as Unix epoch DateTime.");
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
long seconds = new DateTimeOffset(value).ToUnixTimeSeconds();
writer.WriteNumberValue(seconds);
}
}
7. High-Performance Source Generators for Native AOT
In modern .NET 8 and 9 web APIs or serverless functions deployed with Native AOT (Ahead-of-Time compilation), reflection is disabled. System.Text.Json source generators generate serializer code during compilation:
using System.Text.Json.Serialization;
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(OrderEnvelope))]
[JsonSerializable(typeof(List<OrderEnvelope>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
Usage with source-generated metadata:
// Zero-reflection, high throughput deserialization:
OrderEnvelope? order = JsonSerializer.Deserialize(
jsonUtf8Bytes,
AppJsonSerializerContext.Default.OrderEnvelope
);
8. Summary & Workflow Automation
Writing and maintaining C# DTO classes by hand is tedious and error-prone. Use our free, browser-based JSON to C# Class Converter to automatically transform raw JSON payloads into production-ready C# models with System.Text.Json, Newtonsoft.Json, records, and nullable reference types in seconds.