什麼都能存,什麼都能塞,什麼都不奇怪
什麼都能存,什麼都能塞,什麼都不奇怪
前言
串接第三方 API,十之八九會遇到字串狀態碼。
以大家都串過的第三方金流服務來說,訂單的付款狀態就是一串小寫字串:
created → pending → paid → refunded → (failed / expired)一開始 domain model 我就直接用 string 存,然後就踩雷了。
把 API 的「操作狀態 success」塞進了「付款狀態」欄位,編譯器一聲都沒吭。
直到後來查資料,才發現欄位裡躺著一個根本不在狀態清單上的值。

那就改成 enum 吧。但一想到要改 enum,通常會冒出三個顧慮:
- API 回傳的 JSON 會從
"pending"變成數字2,這就把既有契約給破壞了。而且數字很難讀,前端得另外拿對照表才知道2是什麼;要是資料庫也跟著存數字,撈出來一排2、4,根本不知道自己在看什麼。 - Swagger / Scalar 文件裡看到的也是數字,不是有意義的字串。
- 資料庫欄位型別要不要跟著改?會不會需要 migration?
這篇就來把 .NET 9 新增的 [JsonStringEnumMemberName] 講清楚,一個內建 attribute 就能把這三個顧慮一次解掉。順便整理一下最容易踩雷的「兩條序列化管線」跟 query 參數的陷阱。
問題:直接存字串,是能有什麼問題?
先看原本的寫法:
// ❌ domain model 直接用 string
public string Status { get; set; } = string.Empty;
// 這行編譯器不會擋,"success" 是操作狀態,根本不是付款狀態
domain.Status = "success";
// 比對狀態時,魔術字串散落各處
if (domain.Status == "pending") { ... }缺點其實很明顯:
- 沒有型別安全:填錯值編譯器不會擋,上面那行就是我踩過的雷。
- 不知道有哪些合法值:前端、Swagger 看不出到底存在哪些狀態。
- 魔術字串散落各處:比對狀態時到處寫
== "pending",手滑打錯一個字母就是一個 bug。
理想當然是改成 enum,只是前言那三個顧慮得先處理掉。
解法核心:enum + 兩個 attribute
using System.ComponentModel;
using System.Text.Json.Serialization;
/// <summary> 訂單付款狀態 </summary>
public enum PaymentStatusDomainEnum
{
[Description("未知")]
[JsonStringEnumMemberName("unknown")]
Unknown = 0,
[Description("已建立")]
[JsonStringEnumMemberName("created")]
Created = 1,
[Description("待付款")]
[JsonStringEnumMemberName("pending")]
Pending = 2,
[Description("付款成功")]
[JsonStringEnumMemberName("paid")]
Paid = 4,
// ... refunded / failed / expired 略
}每個成員掛兩個 attribute,各做各的事:
| Attribute | 命名空間 | 負責 |
|---|---|---|
[Description("待付款")] | System.ComponentModel | Swagger / Scalar 顯示的中文說明 |
[JsonStringEnumMemberName("pending")] | System.Text.Json.Serialization | JSON 序列化時對應的字串值 |
[JsonStringEnumMemberName] 是 .NET 9 才新增的內建 attribute,官方的定義就是「決定 enum 成員序列化成字串時要用哪個值」,而且序列化、反序列化兩個方向都適用。所以不用自己造輪子。
不要拿 [EnumMember] 來用
很多人第一反應會是 [EnumMember(Value = "...")],但那是 DataContractSerializer 跟 Newtonsoft.Json 的慣例。
System.Text.Json 的 JsonStringEnumConverter 根本不吃 EnumMember,只認 JsonStringEnumMemberName。
掛錯了也不會報錯,就只是默默沒作用而已,這種雷最難查。
關鍵前提:JsonStringEnumConverter 是總開關
[JsonStringEnumMemberName] 只有在 enum 被當成字串序列化的時候才會生效。
少了 JsonStringEnumConverter,enum 預設就是數字,這個 attribute 會被整個忽略掉。
三者的關係大概像這樣:
JsonStringEnumConverter → 開啟「enum 當字串」這個行為
│
└─ 序列化時會去讀 JsonStringEnumMemberName → 決定輸出哪個字串為什麼要註冊兩次?(兩條各自獨立的管線)
在 Program.cs 裡:
// 管線 1:OpenAPI schema 產生 (+ Minimal API)
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// 管線 2:控制器 (MVC) 執行期序列化
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});這裡就是最容易踩雷的地方了。
這兩個註冊點對應的是兩條不同的管線,少設一個,就會有一邊偷偷退回數字:
| 註冊點 | 影響範圍 | 少了它會怎樣 |
|---|---|---|
AddControllers().AddJsonOptions | 執行期控制器 request/response | API 實際回傳變成數字 2 |
ConfigureHttpJsonOptions | OpenAPI schema 產生 | Scalar / Swagger 的 schema 把 enum 列成整數,而非 created / pending / ... |
官方文件寫得很明白:enum 掛了 JsonStringEnumConverter 之後,「產生的 schema 會有一個 enum 屬性,內容是該 enum 的字串值」。
但 AddControllers 的 JsonOptions 管不到 schema 產生這件事,所以才需要 ConfigureHttpJsonOptions 這條另外補上。
兩條都設好之後,API 回傳的 JSON 契約維持 "pending" 不變,Scalar 文件那邊也會乖乖顯示字串 enum 加中文說明。
為什麼不乾脆用命名原則就好?
眼尖的人應該發現了:狀態碼 created / pending / paid 剛好就是成員名稱的小寫。
那給 JsonStringEnumConverter 掛一個 camelCase 命名原則,不就搞定了嗎?
兩個方案並排看看:
// 全域一行,所有 enum 成員名稱自動轉小寫駝峰
options.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
// Pending → "pending",不需要在 enum 上掛任何東西
public enum PaymentStatusDomainEnum
{
Unknown = 0,
Created = 1,
Pending = 2,
Paid = 4,
}// 每個成員明確標註對應的字串值
options.Converters.Add(new JsonStringEnumConverter());
public enum PaymentStatusDomainEnum
{
[JsonStringEnumMemberName("unknown")]
Unknown = 0,
[JsonStringEnumMemberName("created")]
Created = 1,
[JsonStringEnumMemberName("pending")]
Pending = 2,
[JsonStringEnumMemberName("paid")]
Paid = 4,
}| 面向 | 命名原則 | JsonStringEnumMemberName |
|---|---|---|
| 標註成本 | ✅ 全域一行 | ❌ 每個成員一行 |
| 不規則字串值 | ❌ 只能做規則轉換 | ✅ 任意值,連 "Partly cloudy" 這種帶空格的都行 |
| 對應關係可見度 | ❌ 藏在全域設定,看 enum 看不出來 | ✅ 對應值就寫在成員旁邊 |
| 影響範圍 | ❌ 全專案所有 enum 都被轉小寫 | ✅ 只影響有標註的成員 |
| 反射讀值(下一節的擴充方法) | ❌ 沒有 attribute 可讀 | ✅ 可用反射讀出,JSON / DB 同源 |
我最後選了方案二,關鍵在表格的最後兩列。
命名原則是全域行為,一掛下去,專案裡其他 enum 也會被一起轉小寫,尤其那些照 C# 慣例走 PascalCase 的,影響範圍太大了。
再說外部 API 的字串碼是別人定的,今天剛好長得像小寫成員名,哪天對方加一個 partially_refunded,你就得為它破例。
逐一標註是囉唆了點,但等於把「外部契約」直接寫死在程式碼裡,哪個成員對哪個字串,看一眼就知道。
加值:enum ↔ 字串的通用擴充方法
DB 這邊我決定維持存字串,不存 int。
這樣做有兩個好處:改 enum 不用 migration,因為欄位型別根本沒變;而且對方哪天新增一個我還沒定義的狀態,也有容錯的空間。
分工大概是:domain 用 enum、entity 存字串,中間在 repository 的 mapping 做轉換。
轉換靠兩個擴充方法。
它們只用反射去讀內建的 JsonStringEnumMemberName(跟 [Description] 一樣都是 BCL 型別),所以擺在共用的 Extensions 專案裡,不用多加任何專案參考:
using System.Reflection;
using System.Text.Json.Serialization;
public static class EnumExtension
{
/// <summary> 列舉 → 字串(寫 DB / 送外部 API);讀 [JsonStringEnumMemberName],未標註則回傳成員名稱 </summary>
public static string GetStringValue(this Enum value)
{
FieldInfo? field = value.GetType().GetField(value.ToString());
var attribute = field?.GetCustomAttribute<JsonStringEnumMemberNameAttribute>();
return attribute?.Name ?? value.ToString();
}
/// <summary> 字串 → 列舉(讀 DB / 收外部 API);找不到對應時回傳預設值,不拋例外 </summary>
public static TEnum ToEnum<TEnum>(this string? stringValue, TEnum defaultValue = default)
where TEnum : struct, Enum
{
if (string.IsNullOrWhiteSpace(stringValue)) return defaultValue;
foreach (FieldInfo field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static))
{
var attribute = field.GetCustomAttribute<JsonStringEnumMemberNameAttribute>();
string memberValue = attribute?.Name ?? field.Name;
if (string.Equals(memberValue, stringValue, StringComparison.OrdinalIgnoreCase))
return (TEnum)field.GetValue(null)!;
}
return defaultValue; // 外部回傳我方尚未定義的狀態 → 落到 Unknown,不炸掉
}
}用起來像這樣:
// 寫入 DB
entity.Status = domain.Status.GetStringValue(); // Pending → "pending"
// 讀回 / 接收外部 API 原始字串
domain.Status = entity.Status.ToEnum<PaymentStatusDomainEnum>(); // "pending" → Pending因為讀的是跟 JsonStringEnumConverter 同一個 attribute,GetStringValue() 吐出來的字串會跟 JSON 序列化的結果一模一樣。
JSON、DB、外部 API 三邊的字串都是同一個源頭,不會出現「JSON 吐 pending、DB 卻存成 Pending」這種各寫各的狀況。
命名的小心得:
GetStringValue跟既有的GetDescription算同一家族,都是「從 enum 成員讀某個 attribute」。
反過來的ToEnum<T>就照 .NET 慣用的轉換命名(像ToString、ToList),掛在string上讀起來最順。
陷阱:body 有效,query / route 無效
[JsonStringEnumMemberName] 定的自訂字串,只在 body schema(也就是 request / response body)生效。
要是把 enum 拿去用在 query / route / header / form 參數,那就沒用了。
因為 ASP.NET Core 這些參數的繫結走的是 Enum.TryParse,不是 JSON 序列化,schema 會直接顯示原始的 C# 成員名稱(Pending),完全不理會 JsonStringEnumMemberName:
// ✅ body:schema 顯示 "pending"(吃 JsonStringEnumMemberName)
public record UpdateRequest(PaymentStatusDomainEnum Status);
// ⚠️ query 參數:schema 顯示 "Pending"(走 Enum.TryParse,attribute 被無視)
app.MapGet("/orders", (PaymentStatusDomainEnum status) => ...);放 body 沒事;真的要放 query,就得留意前端送的值跟 body 裡的長得不一樣。
小結
回頭對照前言那三個顧慮,再加上一路整理出來的幾個點:
| 顧慮 / 需求 | 解法 |
|---|---|
| 型別安全 | 用 enum 取代字串 |
| API 契約不能變 | [JsonStringEnumMemberName("pending")] + 全域 JsonStringEnumConverter,JSON 仍輸出 "pending" |
| Swagger/Scalar 可讀性 | [Description("中文")] + ConfigureHttpJsonOptions 那條註冊 |
| DB 要不要 migration | 存字串、entity 維持 string,免 migration |
| 要不要自建 attribute | 不用,JsonStringEnumMemberName 是內建 BCL 型別(.NET 9+) |
| 未知狀態容錯 | ToEnum<T>() 找不到對應回傳 Unknown,不拋例外 |
三個角色的分工,其實記住這樣就好:JsonStringEnumConverter 是「要不要把 enum 當字串」的總開關,JsonStringEnumMemberName 決定字串內容長怎樣,[Description] 則管文件上的顯示。
三個湊在一起,型別安全、API 契約、文件可讀性就能一次全拿。
真正要記在心裡的其實只有兩件事:JsonStringEnumConverter 得在兩條管線各註冊一次(執行期一次、OpenAPI schema 一次),還有自訂字串只在 body 生效,query 跟 route 走的是另一套繫結。
環境:.NET 10 / ASP.NET Core / System.Text.Json。JsonStringEnumMemberName 需要 .NET 9 以上。