Understanding JSON → C#
Two libraries, one mental model.
Why .NET has two JSON libraries, which one to pick now, and the attributes that map JSON keys to C# property names without surprises.
Newtonsoft vs System.Text.Json.
For almost two decades C# JSON meant Newtonsoft (Json.NET) — the de-facto library James Newton-King wrote in 2006 and Microsoft later licensed to a foundation. System.Text.Json shipped with .NET Core 3.0 in 2019 as the new default: faster, allocation-friendlier, but with a smaller surface. Modern projects should target System.Text.Json; legacy code can keep Newtonsoft. Codegen tools emit the attribute flavour for whichever library you pick.
The JsonPropertyName attribute.
C# convention is PascalCase property names; JSON keys are typically snake_case or camelCase. The bridge is [JsonPropertyName("user_id")] on a property named UserId — tells System.Text.Json what to call the field in JSON. For Newtonsoft the same attribute is [JsonProperty("user_id")]; otherwise functionally equivalent. A project-level naming policy (JsonNamingPolicy.SnakeCaseLower in .NET 8+) can avoid per-property attributes when the whole API uses one convention.
Nullable reference types.
C# 8 added nullable annotations (string? vs string) that flow through the type system. For JSON DTOs, mark every field that can be null or missing as nullable; the rest are required. Combined with required modifier (C# 11+), you get a compile-time guarantee that the field exists. The codegen tool's job is to emit nullables correctly based on the source schema — null values in samples become string?, never-null become string.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null }: public class User {
[JsonPropertyName("user_id")] public int UserId { get; set; }
public required string Name { get; set; }
public string? Avatar { get; set; }
} The avatar nullable; the name required; the user_id needs an attribute because the property name doesn't match. Sealed shape, idiomatic .NET.
Classic class
get;set; properties + System.Text.Json
Mutable; ergonomic for ORMs and forms.
public class User { ... required Name ... }
= Standard DTO shape
JsonStringEnumConverter.
Enums round-trip as integers by default, which is rarely what you want — the JSON "status": "Active" deserializes correctly into an enum only if you register JsonStringEnumConverter or annotate the type with [JsonConverter(typeof(JsonStringEnumConverter))]. The codegen output for a string-valued enum field always needs that converter to round-trip cleanly. Forgetting this turns "Active" into a deserialization error.
Init-only setters and records.
C# 9 added init-only setters: public string Name { get; init; } means the property can be set during object construction and never again. Combined with C# records (next chapter), this gives you immutable-by-default DTOs without giving up the deserializer's ability to set properties. The recommended modern shape: properties with init setters on a regular class, or a record (more on which is right when).
Source generators for high performance.
.NET 6 introduced JSON source generators — compile-time generated serializers that beat the reflection-based defaults by 2-5×. Annotate a partial context class with [JsonSerializable(typeof(User))], and the compiler emits zero-reflection deserialization code. For a hot path (request handling in a high-throughput service), source generators are free performance. For most application code, the default reflection-based path is fine.