🌍 Languages:
🇬🇧 English (this file) | 🇫🇷 Français
| Build | |
| Quality | |
| Security | |
| Packages | |
| Project |
One enum contract, honoured on every input channel — not just the request body.
Since .NET 9, System.Text.Json lets you give an enum member an explicit public name:
using System.Text.Json.Serialization;
public enum ProductStatus
{
[JsonStringEnumMemberName("available")] Available,
[JsonStringEnumMemberName("out_of_stock")] OutOfStock,
[JsonStringEnumMemberName("discontinued")] Discontinued
}That name is honoured in the request body, and nowhere else. ASP.NET Core binds route values,
query strings, form fields and headers through System.ComponentModel, which has never heard of
System.Text.Json. So the same API answers:
POST /products {"status":"out_of_stock"} → 200
GET /products?status=out_of_stock → 400
GET /products?status=OutOfStock → 200 ← your internal C# name, now part of your public contract
This package closes that gap.
dotnet add package AspNetCore.EnumMemberNameBinding
Requires ASP.NET Core MVC (controllers), on a supported .NET:
| Package | .NET |
|---|---|
| 1.x | 10 |
Take the latest version — NuGet resolves the target framework matching your project. The package version describes this library's own public surface, so a new .NET release adds a row to that table rather than moving the major.
One line, at start-up:
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
.AddEnumMemberNameBinding();
var app = builder.Build();
app.MapControllers();
app.Run();That is all. Nothing to annotate beyond the [JsonStringEnumMemberName] attributes you already have.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("products")]
public sealed class ProductsController : ControllerBase
{
[HttpGet("{status}")]
public IActionResult ByStatus([FromRoute] ProductStatus status) => Ok(status);
[HttpGet]
public IActionResult Search([FromQuery] ProductStatus? status) => Ok(status);
}GET /products/out_of_stock → 200
GET /products?status=out_of_stock → 200
GET /products?status=OutOfStock → 400 { "errors": { "status": ["The value 'OutOfStock' is not valid."] } }
| Channel | Covered |
|---|---|
| Route values | ✅ |
| Query strings | ✅ |
| Form fields | ✅ |
Headers ([FromHeader]) |
✅ |
Nullable enums (TEnum?) |
✅ |
| Request body | ✅ (by System.Text.Json) |
| OpenAPI document | ✅ with the companion package |
| Minimal API responses | ✅ |
| Minimal API parameters | ❌ — platform-level constraint |
- The same vocabulary everywhere. The matching rules are not invented here, they are a port of
the ones
System.Text.Jsonapplies to the request body — down to the whitespace, the comma-separated list and its trailing comma. Every rule was measured againstJsonSerializer, never read off a specification. - Verified, not declared. The test suite runs every candidate input through both
JsonSerializerand a live HTTP request and requires the two outcomes to be identical. If .NET changes its matching rules, the build fails. - Nothing else changes. An enum that carries no
[JsonStringEnumMemberName]is left completely alone: same binding, same validation, same JSON wire format as without this package. The globalJsonStringEnumConverterfactory is never installed — one converter is registered per contract enum. Nor does any other application: everything is registered in the calling application's own container, so a second host in the same process is untouched. - Mistakes are build errors. Roslyn analyzers ship inside the package, no extra install: a duplicate public name, an incomplete contract or a name that shadows another member's C# name is reported in your editor, not discovered at start-up. Enums that declare no contract are never analysed.
- Validation is preserved. An unknown or numeric value is a 400, exactly as the body would refuse it.
The two worth knowing before you adopt it:
- Minimal API parameters are not supported. Their binding requires a
static TryParseorBindAsyncon the bound type, which cannot be added to anenum— a platform-level constraint, not an implementation gap. Responses are covered. - Link generation does not use the public name. ASP.NET Core formats route values without
the value's own
ToString(), so a link built from the enum value carries the C# name and this very API answers 400 to it.EnumMemberNames.GetPublicName(value)is the way round.
The full list — empty values, channel portability, trimming and Native AOT, and why registration must happen at start-up — is in limitations.
- Contract rules
— what is accepted, request by request: fully and partially annotated enums,
[Flags], empty and absent values, which names can travel on which channel, and the configuration options. - Analyzers
—
EMN0001toEMN0006, whyEMN0005is worth reading twice, and how to configure severities. - OpenAPI
— the companion package, the
[Flags]pattern, and how document/runtime coherence is verified. - Limitations
- Changelog
This package supersedes Reefact.JsonEnumValueBinding, which predates .NET 9 and carried its own
[JsonEnumValue] attribute and its own JSON converter. Both are now redundant: the platform owns
the attribute and the serialization. Migration is an attribute rename —
[JsonEnumValue("x")] becomes [JsonStringEnumMemberName("x")] — plus swapping
AddJsonEnumValueBinding() for AddEnumMemberNameBinding().
- Report a bug or request a feature — the issue tracker. A suspected vulnerability goes through the private channel below instead.
- Contributing — how to build and test, the coding style, the commit grammar, and what a pull request carries.
- Security policy — what is in scope, and how to report a vulnerability privately.
Apache-2.0.