Adapter & Extensions
The casing rules, per-type configuration, and serializer options described in the rest of this guide are translated into the underlying serializer’s native format by an adapter.
ConfigureAdapter
ConfigureAdapter<TAdapter, TOptions> selects the active serializer adapter and (optionally) reaches the low-level settings of that serializer that aren’t exposed through the standard options.Json.* API.
The default: System.Text.Json
Out of the box, AspNetConventions uses SystemTextJsonAdapter, which is built on System.Text.Json (included with .NET — no extra dependency).
You don’t need to do anything to opt in. If ConfigureAdapter is never called, the framework wires up SystemTextJsonAdapter automatically:
builder.Services
.AddControllers()
.AddAspNetConventions(options =>
{
// SystemTextJsonAdapter is used implicitly
options.Json.CaseStyle = CasingStyle.SnakeCase;
});
Reaching native System.Text.Json settings
When you need a JsonSerializerOptions setting that isn’t surfaced on options.Json — a custom encoder, native DefaultIgnoreCondition, or any other low-level knob — call ConfigureAdapter with the default adapter and configure the options directly:
options.Json.ConfigureAdapter<SystemTextJsonAdapter, JsonSerializerOptions>(serializerOptions =>
{
serializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
serializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
serializerOptions.Converters.Add(new MoneyJsonConverter());
});
The configure delegate runs after AspNetConventions has applied its own rules, so any settings you change here win.
Serialization Hooks
JsonSerializationHooks provides four extension points across two phases.
Startup-time hooks (run once per type at initialization, then cached by the adapter):
// Skip rule processing for internal types
options.Json.Hooks.ShouldSerializeType = type =>
!type.Namespace?.StartsWith("MyApp.Internal") ?? true;
// Override property names programmatically
options.Json.Hooks.ResolvePropertyName = (clrName, type) =>
clrName == "Id" ? "identifier" : null;
// Log resolved types and their final JSON property names
options.Json.Hooks.OnTypeResolved = (type, jsonNames) =>
logger.LogDebug("[JSON] {Type}: {Props}", type.Name, string.Join(", ", jsonNames));
Per-serialization hook (called on every serialization — keep it fast):
// Suppress empty strings from any type
options.Json.Hooks.ShouldSerializeProperty = (instance, value, clrName, type) =>
value is not string s || s.Length > 0;
Property names are resolved at startup and cached by the adapter, so they cannot be changed per request through hooks. For per-request name changes, use a custom ICaseConverter backed by a scoped service.
See JsonSerializationHooks for the full reference.
Framework Defaults
AspNetConventions applies a set of built-in rules to its own response types to ensure sensible defaults out of the box. These rules run before your ConfigureTypes delegate, so your configuration can always override them:
| Type | Property | Default rule |
|---|---|---|
ApiResponse |
Metadata |
WhenWritingNull |
DefaultApiResponse |
Pagination |
WhenWritingNull |
PaginationMetadata |
Links |
WhenWritingNull |
PaginationMetadata |
HasNextPage |
WhenWritingNull |
PaginationMetadata |
HasPreviousPage |
WhenWritingNull |
These defaults keep response payloads lean: navigation links and pagination flags are only included when the response is actually paginated, and metadata blocks are omitted on responses that don’t populate them.
Examples
Complete working examples demonstrating JSON Serialization configuration.
Customizing ApiResponse
ApiResponse is the abstract base for all standard response envelopes. It exposes Status, StatusCode, Message, and Metadata. The example below drops StatusCode from the output and moves Message to the end, only when present:
options.Json.ConfigureTypes = cfg =>
{
cfg.Type<DefaultApiResponse>(type =>
{
type.Property(x => x.Status).Order(0);
type.Property(x => x.StatusCode).Ignore();
type.Property(x => x.Data).Order(1);
type.Property(x => x.Pagination).Order(2);
type.Property(x => x.Metadata).Order(3);
type.Property(x => x.Message).Order(4).Ignore(IgnoreCondition.WhenWritingNull);
});
};
Serialized output (success, no message):
{
"status": "success",
"data": { "id": 1, "name": "Alice" }
}
Customizing PaginationLinks
PaginationLinks holds the navigation URLs for paginated responses. You can rename the properties to a shorter, client-friendly contract:
options.Json.ConfigureTypes = cfg =>
{
cfg.Type<PaginationLinks>(type =>
{
type.Property(x => x.FirstPageUrl).Name("first");
type.Property(x => x.LastPageUrl).Name("last");
type.Property(x => x.NextPageUrl).Name("next");
type.Property(x => x.PreviousPageUrl).Name("prev");
});
};
Serialized output:
{
"status": "success",
"data": [...],
"pagination": {
"pageNumber": 1,
"pageSize": 25,
"totalPages": 40,
"totalRecords": 1000,
"links": {
"first": "/api/orders?page-number=1&page-size=25",
"last": "/api/orders?page-number=40&page-size=25",
"next": "/api/orders?page-number=2&page-size=25",
"prev": null
}
}
}