[NotMapped] 很容易被誤解成「暫時不建欄位,但程式仍可照常查詢與儲存」。實際上,它是在模型層明確排除持久化映射:屬性可以承載讀取後組裝的顯示資料,卻不會自動成為資料表欄位、不能直接放進資料庫端 IQueryable 篩選,也不會因為呼叫 SaveChanges 就被寫回。若團隊拿它當作外鍵、流程狀態或唯一寫入成果,程式可能沒有編譯錯誤,資料庫也不一定拋例外,但真正需要保存的關聯仍然不存在。
EF Core NotMapped 的核心結論
[NotMapped] 適合表達「由其他已映射資料推導、只在記憶體使用」的讀取模型欄位;凡是需要被查詢、建立關聯、判斷流程或跨請求保存的值,都必須落在可映射欄位或實體關係上。
EF Core 依慣例會把公開且可讀寫的屬性納入模型,也允許透過 [NotMapped] 或 Fluent API 的 Ignore 排除屬性。排除後,該屬性不再是持久化模型的一部分。這不是「稍後再處理」的提示,而是模型契約:建立資料表時不會替它產生欄位,載入實體時不會從資料庫填值,追蹤變更時也不會把它視為可儲存屬性。
最安全的用途是讀取附掛。例如列表需要顯示多個標籤名稱,但資料庫實際保存的是關聯列與標籤表;應用程式可以先查主體,再批次查關聯,最後把整理後的名稱填進 [NotMapped] List TagNames。這個欄位描述的是 API 或快取需要的結果形狀,而不是新的資料所有權。真正可寫入的來源仍是標籤表與關聯表。
危險用法則是把 [NotMapped] 當成關聯捷徑。假設一筆中介關聯只有 TemplateId 是真實外鍵,而 ArticleId 是由 Template.TargetArticleId 推導;若建立新文章後只寫入 relation.ArticleId = newId,SaveChanges 不會保存這個值。新文章可能成功建立,但中介列沒有任何物理欄位指向它,最後形成孤兒資料。問題不在 EF Core 忽略錯誤,而在程式選擇把唯一產出寫到一個明確被排除的屬性。
因此可用一句話審查:這個值若在重新載入實體後仍必須存在,就不應只存在於 [NotMapped]。 顯示文字、彙總結果、相容層別名與讀取後附掛資料通常合適;外鍵、狀態、冪等鍵、擁有者、時間戳與流程是否完成的依據通常不合適。
讀取模型與寫入契約要分開
正確設計不是禁止 [NotMapped],而是把讀取方便性與寫入真相分離:讀取端可以附掛推導值,寫入端只能修改可持久化的外鍵、關聯列或明確映射欄位。
在讀取端,先找出資料的物理來源。若顯示欄位來自多對多關聯,應以主體識別碼批次查詢關聯與名稱,再於 materialize 之後組裝。這樣能避免每列額外查詢,也能讓相同附掛邏輯被 API、背景工作與快取載入共用。若系統同時有直接 EF 路徑與快取路徑,附掛點要放在兩條路徑都會經過的位置,否則相同查詢可能因是否命中快取而得到不同結果形狀。
在寫入端,命令 DTO 應表達真正可保存的資料。例如更新標籤時,前端可以送既有標籤識別碼或新名稱,但後端必須自行決定 TargetId、資源類型與關聯狀態,並建立或更新實際關聯列。不要要求前端組出完整關聯實體,也不要把 [NotMapped] TagNames 直接當成 ORM 可追蹤集合。顯示模型與寫入模型看起來相近,不代表它們有相同責任。
null 與空集合也應有明確語意。更新命令中的 tags = null 可以代表「此次不處理標籤」,空集合則代表「移除所有標籤」。如果先把兩者都正規化成空集合,讀取附掛雖然仍正常,寫入契約卻會把未提供欄位誤解成清空操作。這類差異應在 command handler 內明確處理,而不是依賴 entity 預設值。
若讀取模型要被序列化到分散式快取,還要處理版本差異。新增 [NotMapped] 欄位後,舊快照不會神奇地出現新資料;它可能反序列化成 null 或空值。部署時應決定是否重建快取、提高快照版本、允許回源補齊,或在讀取端做相容處理。[NotMapped] 排除的是資料庫映射,不代表其他序列化器也會忽略它;JSON 是否包含該欄位取決於序列化設定。
建議在程式碼中留下方向性註解,例如「read enrichment only」或「derived from mapped relation」。比起只寫「不進 DB」,方向性更能阻止後續維護者拿它作為寫入目標。若同一個類別同時承擔 entity 與 API response,至少要以測試鎖定哪些屬性可保存、哪些只能在查詢完成後填入;更大型的系統則應考慮專用 projection DTO,降低責任混合。
為何 NotMapped 不能直接放進 IQueryable
IQueryable 的 Where 必須被資料庫提供者翻譯成 SQL;[NotMapped] 沒有對應欄位或 SQL 運算式,因此直接以它篩選通常會在執行時得到無法轉譯的例外,而不是在資料庫中完成查詢。
例如 db.Relations.Where(x => x.ArticleId == id) 看似合理,但若 ArticleId 是 [NotMapped],EF Core 無法從模型得知它應該沿著哪一條關聯鏈計算。C# getter 即使能在記憶體執行,也不等於關聯式提供者能把任意程式碼翻譯成 SQL。EF Core 允許頂層投影中的部分內容在用戶端計算,但不可轉譯的篩選條件位於 Where 時,會丟出執行例外,以避免把整張表載入應用程式後才偷偷過濾。
正確做法是回到物理鏈。若關係是 MaterialRelation.TemplateId → Template.Id → Template.TargetArticleId,查詢就應明確 join Template,並以 TargetArticleId 篩選。這不只是為了讓 SQL 可產生,也是在程式碼中保留資料真相:任何人都能看到文章關聯實際透過模板建立,而不是誤以為中介表真的有 ArticleId 欄位。
以下為查詢節錄,並非獨立程式;db.MaterialRelations 與 targetArticleId 由應用程式提供。完整沙箱改用 db.Relations,定義見重現章節。
var relationIds = await (
from relation in db.MaterialRelations
join template in db.Templates
on relation.TemplateId equals template.Id
where template.TargetArticleId == targetArticleId
select relation.Id
).ToListAsync();
若附掛值是純顯示需求,可以先完成資料庫篩選與投影,再於明確的 materialization 邊界後計算。例如先選出必要欄位並執行 ToListAsync,再建立 response DTO。不要為了讓 [NotMapped] 篩選「能跑」就在查詢前加 AsEnumerable;這會把後續條件搬到記憶體,可能造成不受控制的資料傳輸與配置。只有資料集已被嚴格縮小、界線可審查且測試能證明容量安全時,才應刻意切換到 client-side LINQ。
另一個常見誤區是以 [NotMapped] 值做「是否已建立」或「是否要通知」的判斷。重新查詢後該值預設為空,流程便可能每次都判定為尚未建立,反覆新增資料或送出事件。判斷應改用可保存的外鍵、關聯是否存在、唯一鍵或冪等紀錄。讀取附掛可以提升顯示便利性,卻不應成為流程真相的唯一來源。
寫入無聲失效的診斷方式
診斷 [NotMapped] 問題時,不要只看 SaveChanges 是否回傳成功;應同時檢查 EF 模型、資料表 schema、重新載入結果與實際關聯列,證明預期值真的跨越了持久化邊界。
第一步是檢查模型 metadata。透過 db.Model.FindEntityType(typeof(Entity)) 取得屬性集合,確認預期保存的名稱是否存在;若屬性被排除,就不應期待 Change Tracker 追蹤它。第二步檢查資料庫 schema,在 SQLite 可用 PRAGMA table_info,其他 provider 則使用官方支援的 schema 查詢。測試的重點不是依賴特定查詢語法,而是確認實體設計與實際欄位一致。
第三步是用新的 context 或清除 Change Tracker 後重新查詢。只看原本記憶體物件會產生假象:你剛指派的 [NotMapped] 值仍留在 CLR 物件上,看起來像已保存。重新 materialize 後若值消失,才顯示它從未進入資料庫。第四步檢查真正關聯。若流程聲稱已把 A 連到 B,就斷言外鍵或 join row 存在,而不是只斷言 A 與 B 各自存在。
還要掃描所有對 [NotMapped] 屬性的賦值位置。可分成兩類:查詢完成後的讀取附掛通常合理;建立或更新流程中的回填則需要高度警戒。如果某個 handler 的唯一可觀察成果只是替 [NotMapped] 賦值,該流程在下一次查詢後很可能等同沒有執行。相同屬性若也被拿去判斷重複、通知或流程完成,應一併改成持久化依據。
測試名稱應直接指出邊界,例如 NotMapped_value_is_not_persisted、NotMapped_predicate_is_rejected、Physical_relation_query_returns_row。不要只寫「save works」或「query works」,因為這類廣泛名稱無法保護真正契約。當未來有人移除 attribute、改用 owned type 或增加資料表欄位時,測試也能清楚提醒模型語意已改變。
驗證與重現:.NET 10、EF Core SQLite 10.0.12
本次已在 Windows sandbox 使用 .NET SDK 10.0.400 與 EF Core SQLite 10.0.12 實際執行;測試只使用記憶體 SQLite 與虛構資料,沒有連接既有資料庫或外部服務。
- 建立
net10.0Console 專案,固定Microsoft.EntityFrameworkCore.Sqlite10.0.12,定義一個含[NotMapped] DisplayLabel的Article,並開啟Data Source=:memory:連線。 - 寫入
Title與DisplayLabel後呼叫SaveChangesAsync,清除 Change Tracker 再載入。通過條件是Title存在、DisplayLabel回到null。 - 用
PRAGMA table_info('Articles')檢查 schema。通過條件是欄位只有映射屬性,不包含DisplayLabel。 - 執行
Where(x => x.DisplayLabel == "transient-label")。通過條件是捕捉到包含「could not be translated」的InvalidOperationException。 - 建立
MaterialRelation.TemplateId → Template.TargetArticleId的物理鏈,使用 join 查詢。通過條件是回傳恰好一筆關聯識別碼。
完整隔離測試程式
在新的空白工作目錄建立 verification/notmapped_contract_20261018,將下列兩個檔案依名稱存入,再從該工作目錄執行後續命令。先安裝 .NET SDK;本次觀察到的版本是 10.0.400,未使用 global.json 固定 SDK。首次 restore 需要取得 NuGet 套件;測試程式本身只連線 SQLite in-memory。
NotMappedContract.csproj:
Exe
net10.0
enable
enable
Program.cs:
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder()
.UseSqlite(connection)
.Options;
await using var db = new LabContext(options);
await db.Database.EnsureCreatedAsync();
var article = new Article { Title = "Mapped title", DisplayLabel = "transient-label" };
db.Articles.Add(article);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var reloaded = await db.Articles.SingleAsync();
var persistedCheck = reloaded.Title == "Mapped title" && reloaded.DisplayLabel is null;
Console.WriteLine($"CHECK1_NOT_PERSISTED={(persistedCheck ? "PASS" : "FAIL")}");
var columns = new List();
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info('Articles');";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
columns.Add(reader.GetString(1));
}
}
var schemaCheck = !columns.Contains(nameof(Article.DisplayLabel), StringComparer.OrdinalIgnoreCase);
Console.WriteLine($"CHECK2_NO_COLUMN={(schemaCheck ? "PASS" : "FAIL")};COLUMNS={string.Join(',', columns)}");
var translationRejected = false;
try
{
_ = await db.Articles.Where(x => x.DisplayLabel == "transient-label").ToListAsync();
}
catch (InvalidOperationException ex) when (ex.Message.Contains("could not be translated", StringComparison.OrdinalIgnoreCase))
{
translationRejected = true;
}
Console.WriteLine($"CHECK3_QUERY_REJECTED={(translationRejected ? "PASS" : "FAIL")}");
var template = new Template { TargetArticleId = reloaded.Id };
db.Templates.Add(template);
await db.SaveChangesAsync();
db.Relations.Add(new MaterialRelation { TemplateId = template.Id });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var physicalChainResult = await (
from relation in db.Relations
join candidate in db.Templates on relation.TemplateId equals candidate.Id
where candidate.TargetArticleId == reloaded.Id
select relation.Id
).SingleAsync();
var chainCheck = physicalChainResult > 0;
Console.WriteLine($"CHECK4_PHYSICAL_CHAIN={(chainCheck ? "PASS" : "FAIL")}");
return persistedCheck && schemaCheck && translationRejected && chainCheck ? 0 : 1;
public sealed class LabContext(DbContextOptions options) : DbContext(options)
{
public DbSet Articles => Set();
public DbSet Templates => Set();
public DbSet Relations => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasOne().WithMany().HasForeignKey(x => x.TargetArticleId);
modelBuilder.Entity()
.HasOne().WithMany().HasForeignKey(x => x.TemplateId);
}
}
public sealed class Article
{
public int Id { get; set; }
public required string Title { get; set; }
[NotMapped]
public string? DisplayLabel { get; set; }
}
public sealed class Template
{
public int Id { get; set; }
public int TargetArticleId { get; set; }
}
public sealed class MaterialRelation
{
public int Id { get; set; }
public int TemplateId { get; set; }
[NotMapped]
public int? ArticleId { get; set; }
}
dotnet --version
dotnet restore verification/notmapped_contract_20261018/NotMappedContract.csproj
dotnet build verification/notmapped_contract_20261018/NotMappedContract.csproj --configuration Release --no-restore
dotnet run --project verification/notmapped_contract_20261018/NotMappedContract.csproj --configuration Release --no-build
實際輸出為 CHECK1_NOT_PERSISTED=PASS、CHECK2_NO_COLUMN=PASS;COLUMNS=Id,Title、CHECK3_QUERY_REJECTED=PASS 與 CHECK4_PHYSICAL_CHAIN=PASS,程序退出碼為零。這些結果驗證指定版本與 SQLite provider 的模型、儲存及轉譯行為,不是效能 benchmark,也不能取代其他 provider 的整合測試。
若專案使用 SQL Server、MySQL 或 PostgreSQL,保留「清 tracker 後重載」與「物理關聯存在」兩項 provider-independent 斷言,再以該 provider 的暫時資料庫驗證 schema 與 SQL。不要把 SQLite 的錯誤文字逐字當成跨 provider 契約;應斷言例外類型、查詢未完成,以及沒有悄悄切到用戶端篩選。
預期輸出如下;本次 Editor 重跑亦得到相同結果。所有布林檢查為 true 時退出碼為 0;預期的轉譯例外被捕捉,屬於通過條件。檢查為 false 或出現未預期例外時以非零碼結束。此測試沒有涵蓋快取、併發或所有資料庫 provider。
CHECK1_NOT_PERSISTED=PASS
CHECK2_NO_COLUMN=PASS;COLUMNS=Id,Title
CHECK3_QUERY_REJECTED=PASS
CHECK4_PHYSICAL_CHAIN=PASS
Code Review 與發布前檢查清單
合併前要確認每個 [NotMapped] 都有明確方向:它從哪些已映射資料推導、在哪個 materialization 後附掛、是否進入快取,以及任何寫入或流程判斷是否誤把它當成持久化真相。
- 屬性是顯示/彙總資料,還是重新載入後仍必須存在的業務資料?
- 若必須保存,是否有實際欄位、外鍵、join row 或 owned entity 承載?
- 是否有
Where、OrderBy、Join或GroupBy直接引用[NotMapped]? - 可否改由映射欄位或物理關聯鏈完成資料庫端查詢?
- 是否有人先呼叫
AsEnumerable來掩蓋轉譯失敗?資料量上限是否可證明? - 寫入 handler 是否只替
[NotMapped]賦值,卻沒有更新真實關聯? - 是否以該值判斷「已建立」「已通知」或冪等狀態?
SaveChanges後是否用新 context 重新載入並檢查關聯,而非只看原物件?- 新欄位是否需要快取重建、版本提升或回源補齊?
- API response、entity 與 command DTO 是否責任過度混合?
North Star Metric 應是「持久化契約測試通過率」,而不是 SaveChanges 成功次數。可輔助追蹤孤兒列數、不可轉譯查詢例外數、快取回源補齊率與重複建立事件數。這些訊號能反映模型邊界是否被誤用,比單看 HTTP 200 更接近真正資料完整性。
結語
[NotMapped] 是讀取模型工具,不是免費的影子欄位。它能讓 entity 或 response 暫時承載推導資料,但不會替系統建立資料庫欄位、外鍵語意、SQL 轉譯或跨請求持久化。
可維護的做法是:讀取時由物理關聯批次附掛,查詢時使用可映射欄位與明確 join,寫入時更新真正外鍵或關聯列,最後以清除追蹤後的重新載入與 schema 檢查證明結果。只要團隊堅持「顯示形狀不等於寫入真相」,[NotMapped] 就能保持便利,而不會變成無聲失效與孤兒資料的來源。