前言

在 Laravel 專案裡,如果一百個後台列表都有相似的篩選、排序與分頁,我們可能會建立一個共用 Repository,讓大家從同一個入口取資料。轉到 C# 後,卻常看到另一種建議:直接使用 EF Core,把查詢條件抽成 IQueryable<T> 擴充方法。兩邊都在解決重複程式碼,差異到底在哪裡?

類似的疑問也會出現在 Middleware、Service Provider、Model、Event 與 Validation。熟悉 Laravel 的經驗很有用,但遷移時需要重新確認三件事:這個元件負責什麼、活多久,以及在哪個時機真正執行工作。 這篇以 ASP.NET Core 與 EF Core 為核心,整理這些概念的對應與實務取捨。

本文依 2026-10-16 查閱的 Laravel 13.x、ASP.NET Core 10 與 EF Core 官方文件整理。C# 程式碼是聚焦概念的片段,省略完整型別、DI 註冊與錯誤回應整合,未建立或執行完整範例專案;版本特有行為會另外註明。

一、先用職責對照,避免只翻譯名稱

有些概念十分接近,有些則需要拆成幾個元件。這張表提供閱讀方向,後面再用具體情境說明。

Laravel 常見概念 在 ASP.NET Core/EF Core 中要看的元件 遷移時需要確認
HTTP Middleware ASP.NET Core Middleware 都是請求管線;檢查順序與所在位置
Service Provider 的 register/boot 啟動時的服務註冊、模組初始化 與 .NET 的 IServiceProvider 職責不同
Container 的 bind/scoped/singleton Transient/Scoped/Singleton 比對實例共用範圍與釋放時機
Eloquent Model Entity、DbContext、DbSet<T>、EF 對應設定 查詢與儲存責任分散到不同元件
Local Scope、查詢 Builder 查詢擴充方法、Expression、Query Service 區分查詢組裝與查詢執行
團隊自行建立的 Repository Repository、Query Service 或 Use Case 依裡面的責任重新命名與切分
FormRequest Request DTO、Validator、Authorization Policy 輸入驗證與權限可能分開配置
API Resource Response DTO 與投影/轉換 回應契約與資料庫實體分開演進
Event/Listener 程序內通知或持久化訊息 是否跨程序、是否保存、如何重試
Queue Job/Worker 背景工作者與佇列處理方案 async 本身沒有提供工作佇列
Global Scope、SoftDeletes Global Query Filter 與軟刪除寫入策略 查詢過濾和刪除行為分別處理
Migration EF Migrations、Snapshot,或既有 schema 的管理流程 確認結構變更的來源與部署方式

這些對應也不表示 Laravel 必須採用某種分層、C# 必須採用另一種分層。兩邊都能使用服務、領域模型與查詢物件;選擇要跟專案的規則與變動方式對齊。

二、生命週期:先分清程序、請求與工作

從傳統 Laravel HTTP 開發轉過來,最值得先確認的是哪些物件會留到下一次請求。

PHP-FPM 的 worker 可以重用,Laravel 應用通常隨請求啟動

一般 PHP-FPM 部署會讓每次 HTTP 請求進入 Laravel 的啟動與處理流程,包括建立應用、載入 Provider,再進入路由。FPM 的子程序可以持續處理多次請求,因此不能把它理解成每次請求都重新啟動作業系統程序。Laravel:Request Lifecycle、PHP:FPM configuration

Laravel Octane 的應用則會常駐,Provider 的啟動方法不會在每次請求重新執行;框架會重設一些狀態,但自訂全域狀態仍須自行管理。所以「PHP 世界沒有跨請求狀態問題」這種推論,無法套用到所有 Laravel 執行方式。Laravel:Octane

ASP.NET Core 應用常駐,服務是否共用取決於註冊方式

ASP.NET Core 的請求會進入持續執行的應用程式,不同請求可能並行使用同一個 Singleton。這不代表每個請求都固定占用一條執行緒;非同步 I/O 會影響執行與等待方式。

實務上先建立一個判斷習慣:

  • 目前使用者、租戶、一次操作的 DbContext,應有明確的請求或工作範圍。
  • 跨請求共用的可變物件,要處理並行存取與狀態歸屬。
  • HTTP 請求結束後,背景工作要使用自己的依賴範圍。

EF Core 的 DbContext 以短生命週期的工作單位設計,也不支援在同一個實例上並行執行多個操作。Microsoft:DbContext lifetime

三、Service Provider:同名,但不是同一種元件

Laravel 的 AppServiceProvider 是應用啟動的一部分,負責註冊與初始化功能;.NET 的 IServiceProvider 則是用來解析已註冊服務的容器介面。把兩者當成同一個角色,容易把初始化與取得服務混在一起。

註冊位置與解析容器分開理解

Laravel Provider 的 register 用於容器綁定,boot 可在各 Provider 完成註冊後執行初始化。對照到 ASP.NET Core,可以把服務註冊整理在 Program.cs 呼叫的 AddOrdering(...) 等模組方法中。Laravel:Service Providers

若需要工作者隨主機啟動、停止,則交由 Hosted Service 的生命週期管理。一般業務類別持續使用建構式注入,讓依賴關係直接顯示在型別上。

Laravel 也有生命週期選項

Laravel 的 bind、singleton、scoped 是不同的註冊方式。scoped 在一次請求或工作生命週期內共用,Octane 處理下一次請求、Queue Worker 處理下一個工作時會清除相關 scoped 實例。Laravel:Service Container

兩邊的概念可以這樣對照:

共用範圍 Laravel 常見方式 .NET 內建 DI
每次解析建立實例 一般 bind 綁定 AddTransient
同一個工作範圍內共用 scoped AddScoped
同一個容器內持續共用 singleton AddSingleton

.NET 的 Scoped 以 DI scope 為單位,Web 應用通常會為每個 HTTP 請求建立 scope。Singleton 則在同一個根容器內共用,需要特別注意它持有的相依物件。Microsoft:Dependency injection

例如 Singleton 工作者若直接持有 scoped DbContext,就會讓後者活得過久。啟用 scope 驗證時可攔下這類錯誤;沒有拋例外,也不能據此認定生命週期正確。

背景工作可以在每個工作開始時建立自己的 scope,完成後釋放。以下片段放在工作者處理單一工作的流程內:

1
2
3
4
5
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider
.GetRequiredService<IRebuildReportHandler>();

await handler.HandleAsync(job, stoppingToken);

租戶、報表範圍等參數從工作資料明確傳入,避免依賴已結束的 HTTP 請求。這種 scope 管理也適用於工作處理器使用 scoped 服務的情境。Microsoft:Use scoped services within a BackgroundService

四、Middleware:兩邊都是管線,差在整合位置與責任

Laravel Middleware 與 ASP.NET Core Middleware 都能在處理請求前後執行工作,也都可以提早回應。Laravel 同時有全域、路由與路由群組的 Middleware;ASP.NET Core 則以應用管線結合路由與端點執行。Laravel:Request Lifecycle、Microsoft:Middleware

下面表示一種常見的 ASP.NET Core 責任順序,省略靜態資源、CORS 等其他元件。實際管線仍要依應用需求配置:

flowchart TD
    R["HTTP 請求"] --> E["例外處理 Middleware"]
    E --> RT["Routing:選出端點"]
    RT --> AU["Authentication:建立身分"]
    AU --> AZ["Authorization:檢查端點授權需求"]
    AZ --> EP["執行端點"]
    EP --> MVC["MVC Filter 與 Controller Action"]
    EP --> MIN["Minimal API Endpoint Filter 與 Handler"]

Routing 之後,Middleware 可以取得已匹配端點的 metadata。因此,不能一概說它只能在路由之前執行,或只能處理完全不懂業務的功能。

權限判斷要選合適的入口

「只有管理員可以進入後台」適合用授權 Policy 與端點需求表達。「目前使用者能否修改這張訂單」則可能需要先載入訂單,再執行資源型授權;只有 [Authorize] 這個標記,無法替你完成所有物件層級的判斷。Microsoft:Resource-based authorization

「已出貨訂單不能取消」屬於狀態規則,需要讓其他用例也能遵守,可以放在 Domain 或明確的業務政策中。

至於需要圍繞 Controller Action 執行的工作,可考慮 MVC Filter;Minimal API 有自己的 Endpoint Filter。這兩者的執行環境不同,不能期待 MVC Action Filter 自動套用到 Minimal API。Microsoft:MVC filters、Minimal API filters

另外,傳統方式建立的 ASP.NET Core Middleware 通常在應用啟動時建立。需要 scoped 服務時,可透過 InvokeAsync 參數取得,或採用由 DI 啟用的 IMiddleware 方式;不要把每個請求的狀態長期留在 Middleware 欄位裡。Microsoft:Dependency injection

五、Model 與 Repository:先辨識你想複用的是什麼

從 Eloquent 轉到 EF Core,要重新拆解查詢、狀態追蹤與儲存的責任,這也直接影響 Repository 是否有價值。

Eloquent Model 的責任,在 EF Core 會分散到不同元件

Eloquent Model 可以透過框架提供的方法查詢、儲存與操作關聯;EF Core 則主要由 DbSet<T> 提供實體集合與查詢入口,DbContext 追蹤變更並儲存。Laravel:Eloquent、Microsoft:DbContext lifetime

EF Core 的 Entity 可以是一般 C# 類別,也可以包含 Order.Cancel() 這類領域行為。它不必只剩屬性;需要維護狀態規則時,可以讓模型負責適合自己的行為,持久化設定則另外安排。前一篇三層、DDD 與垂直切片比較有更完整的取消訂單範例。

在 EF Core 的文件裡,Model 有時還指整套實體對應的 metadata,與你寫的某一個 Entity 類別不同。閱讀時先看它談的是 CLR 物件、HTTP DTO,還是資料庫對應資訊。Microsoft:Creating and configuring a model

Repository 可以封裝共用查詢,也需要有清楚的邊界

Repository 模式以類似集合的介面協調領域物件與資料對應,集中查詢建構、降低重複查詢也是它能提供的價值。因此,Laravel 專案用 Repo 複用資料存取,這個出發點本身合理。Martin Fowler:Repository

當一個 Repo 同時包含查詢、折扣核准、金流呼叫、通知與交易提交時,就值得依責任重新拆分:

想複用的內容 可以考慮的歸屬 主要責任
篩選條件、預設排序 Query Extension、查詢規格 組裝查詢
固定用途的完整列表 Query Service,例如 IOrderListQueries 執行查詢,回傳 DTO/分頁結果
聚合的載入與保存 特定 Repository,例如 IOrderRepository 提供持久化邊界
折扣、取消條件 Domain/Policy 判斷業務規則
結帳、退款申請 Application Service/Use Case 協調資料、規則與外部操作

Repository 可以有業務語意,例如「找出待出貨訂單」。需要用條件更新來落實庫存保留時,也可以由持久化實作負責原子操作。判斷重點是誰決定政策、誰保證寫入正確,以及整個流程由誰協調。

EF Core 上再包 Repository,應該換到什麼收益?

若一層 GenericRepository<T> 只把 Add、Find、Remove 改名轉交,卻沒有建立新的邊界,維護價值可能有限。反過來,若目標是隔離查詢結果、隱藏特殊 SQL,或為應用層提供可替換的資料介面,Repository 就可能有明確用途。

Microsoft 的 EF Core 測試指南甚至把 Repository 列為需要可靠資料層替身時的選項,同時提醒實作與維護成本。因此,「微軟認定 Repository 一律是反模式」並不符合文件內容。Microsoft:Choosing a testing strategy

資料庫切換也需要看實際查詢、型別、大小寫比較與 provider 功能。抽出介面能集中差異,但仍要處理資料模型與資料庫行為,無法只靠一個 Repo 保證切換成本。

六、實戰:一百個後台列表,怎麼複用查詢?

先把共用需求分成兩部分:所有列表共用的機制,以及每個列表獨有的欄位與存取規則。這裡以訂單列表示範:

  • 預設依 Sort ASC, Id DESC 排序,Sort 與唯一的 Id 都不可為 null。
  • 允許客製為金額升冪、降冪,但排序欄位由伺服器白名單決定。
  • 可依訂單編號、最低金額篩選。
  • 租戶範圍由已驗證的伺服器端資訊決定。
  • 回傳固定的列表 DTO,包含總筆數與分頁資訊。

先理解 IQueryable 與執行時機

IQueryable<T> 表達可由 provider 執行的查詢;來源是 EF Core 時,支援的 Expression 才會轉譯成資料庫查詢。任意自訂 C# 方法不一定能轉譯。

IEnumerable<T> 則是可列舉的抽象,本身沒有承諾資料已全部載入。AsEnumerable() 會改變後續 LINQ 組合的處理方式,但不會在呼叫當下就像 ToList() 那樣建立完整清單。Microsoft:Client vs. server evaluation

因此,應該檢查的是哪裡開始列舉、哪些條件仍由資料庫處理。資料在 Repo 裡先完成必要的篩選、投影與分頁,再回傳清單,完全可以是合理的邊界。

第一步:共用預設排序機制

以下讓不同實體傳入自己的排序欄位,不要求每張表都繼承同一個基底 Entity:

1
2
3
4
5
6
7
8
9
10
using System.Linq.Expressions;

public static class AdminQueryExtensions
{
public static IOrderedQueryable<T> ApplyAdminDefaultSort<T, TId>(
this IQueryable<T> query,
Expression<Func<T, int>> sortKey,
Expression<Func<T, TId>> idKey)
=> query.OrderBy(sortKey).ThenByDescending(idKey);
}

傳入 x => x.Sort、x => x.Id 這類可轉譯的成員存取,讓排序留在查詢裡。若其他資料有可為 null 的排序欄位,要另外決定 null 的順序與跨資料庫行為。

第二步:每個列表宣告允許的客製排序

假設 OrderRow 是此例 EF Core 對應的實體,Id 為唯一的 long,TotalAmount 為 decimal。訂單列表用明確的字串契約對應強型別查詢:

1
2
3
4
5
6
7
8
9
10
11
12
private static IOrderedQueryable<OrderRow> ApplyOrderSort(
IQueryable<OrderRow> query, string? sort)
=> sort switch
{
null or "" or "default" => query.ApplyAdminDefaultSort(
x => x.Sort, x => x.Id),
"amount_asc" => query.OrderBy(x => x.TotalAmount)
.ThenByDescending(x => x.Id),
"amount_desc" => query.OrderByDescending(x => x.TotalAmount)
.ThenByDescending(x => x.Id),
_ => throw new QueryInputException("不支援的排序方式")
};

每條路徑都加入唯一的 Id,讓相同金額或相同 Sort 的資料仍有明確順序。EF Core 的分頁文件也提醒,排序需要完全唯一,否則分頁可能出現跳漏。Microsoft:Pagination

一百個列表可以共用排序解析器與白名單描述格式,但每個列表允許哪些欄位,仍需要明確定義。採用 System.Linq.Dynamic.Core 等第三方套件時也一樣:它能處理動態表達式,允許的欄位與語法範圍仍由應用決定。System.Linq.Dynamic.Core 專案

第三步:在完整用例中組裝與執行

這段放在列表 Handler 或 Query Service 裡。QueryInputException 應由 API 轉成 400,PageResult<T> 包含 Items、long 型別的 TotalCount、Page 與 PageSize;頁面大小和偏移量上限是本例的 API 政策。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if (request.Page < 1 || request.PageSize is < 1 or > 100)
throw new QueryInputException("分頁參數無效");

var offset = (long)(request.Page - 1) * request.PageSize;
if (offset > 100_000)
throw new QueryInputException("超過此列表允許的分頁範圍");

var query = db.Orders.AsNoTracking()
.Where(x => x.TenantId == currentTenantId && !x.IsDeleted);

var keyword = request.Keyword?.Trim();
if (!string.IsNullOrEmpty(keyword))
query = query.Where(x => x.OrderNumber.Contains(keyword));
if (request.MinAmount is decimal min)
query = query.Where(x => x.TotalAmount >= min);

var ordered = ApplyOrderSort(query, request.Sort);
var total = await query.LongCountAsync(ct);
var items = await ordered
.Skip((int)offset).Take(request.PageSize)
.Select(x => new OrderListItem(
x.Id, x.OrderNumber, x.TotalAmount))
.ToListAsync(ct);

return new PageResult<OrderListItem>(
items, total, request.Page, request.PageSize);

這裡真正值得共用的是分頁參數檢查、排序組裝機制與回應格式;訂單編號、金額條件、可見範圍則有明確的訂單語意。擴充方法負責組裝,Query Service 負責完成一次查詢用例。

這段查詢有哪些實際限制?

第一,總筆數與資料頁通常是兩次 SQL。它們也不是同一個資料快照;若查詢之間剛好新增或刪除資料,TotalCount 和 Items 可能有些微差異。一般後台可依需求接受,若必須一致,就要另外選擇合適的交易與隔離策略。

第二,同一個 DbContext 上的 Count 與列表查詢要依序 await,不能為了省時間直接一起丟給 Task.WhenAll。需要並行時,必須另行規劃獨立工作單位及一致性要求。Microsoft:DbContext lifetime

第三,唯一排序只能解決順序不明確的問題。Offset 分頁遇到前面資料增刪,仍可能產生重複或跳漏;深頁也會增加跳過資料的成本。只需上一頁、下一頁的介面,可以考慮 keyset/cursor 分頁。Microsoft:Pagination

最後,客製 select 與 where 也需要契約。前端可以選擇的是允許的欄位代號與篩選條件,伺服器再映射成查詢;回應欄位要經過授權與白名單控制,避免把 Entity 的所有屬性當成公開 API。

IQueryable 應該傳到哪裡?

查詢擴充方法回傳 IQueryable,表示我們有意在這個範圍內繼續組合 EF Core 查詢。若介面的目的卻是隔離 EF Core,再把 IQueryable 交給呼叫端組合,就沒有達成完整的查詢隔離。

需要清楚邊界時,可以讓 IOrderListQueries.GetAsync(...) 在內部完成查詢,回傳具體分頁 DTO。這也避免把尚未執行的查詢帶到 DbContext 已釋放的地方。相同邏輯是否跨 API 共用,應看它們是否真的需要同一套查詢契約。

七、Global Scope 與 Query Filter:預設查詢條件不等於完整政策

Laravel Global Scope 與 EF Core Global Query Filter 都能把預設條件加入查詢,適合軟刪除、多租戶等需求。Local Scope 或查詢擴充方法則適合由用例明確選用的條件。Laravel:Query scopes、Microsoft:Global query filters

需要分開確認的是:

  • HasQueryFilter 定義查詢過濾,並沒有自動把刪除改成更新 IsDeleted。
  • 無參數的 IgnoreQueryFilters() 會停用該次查詢的全域過濾;使用方式與權限要受控。
  • EF Core 10 的具名 Filter 可分別管理條件;較舊版本若把租戶與軟刪除合在一起,要注意忽略過濾的影響範圍。
  • 查詢 Filter 無法單獨負責所有寫入授權,原生 SQL 與其他資料存取路徑也要有一致的存取設計。

租戶資訊的生命週期也要正確。若使用 DbContext pooling,context 會被重用,應依官方方式管理每次操作的租戶狀態,不能把租戶值當成啟動時固定的設定。Microsoft:Advanced performance topics

八、Validation:輸入、授權與業務規則分開確認

Laravel FormRequest 能包含驗證與授權邏輯,因此轉到 ASP.NET Core 時,經常需要把原本同一個類別的責任對應到不同元件。Laravel:Form request validation

判斷內容 C# 端常見位置 範例
輸入形狀與格式 Request DTO、Validator 必填、Email 格式、分頁範圍
使用者能否執行操作 Authorization Policy/Handler、資源型授權 是否可修改這張訂單
業務狀態是否允許 Domain/Policy/Use Case 已出貨訂單不能取消
競爭寫入時仍要成立的約束 資料庫約束與併發設計 Email 唯一性、版本衝突

Controller API 搭配 [ApiController] 時,模型驗證錯誤預設可觸發 400;Minimal API 的整合方式要依版本與註冊確認。ASP.NET Core 10 提供 AddValidation() 的整合,不能由 MVC 的行為推論所有 Minimal API 都已自動驗證。Microsoft:Web APIs、Validation

Laravel JSON 驗證常見的 422,與 C# 範本裡的 400,是需要在 API 契約中對齊的差異。若前端仰賴特定錯誤格式,遷移時應一起整理欄位路徑、錯誤碼與 HTTP 狀態。

另外,驗證器先查到 Email 沒被使用,不代表另一個請求不會同時建立相同 Email。這類規則仍要由資料庫約束兜住,並將衝突轉成可理解的回應。

九、Event、Job 與 async:分別確認通知和交付保證

事件描述某件事發生,工作則描述需要執行的任務。兩者可以連接,但還需要決定在哪裡執行,以及程序中斷後能否恢復。

Laravel Listener 實作 ShouldQueue 後可以交給佇列處理,但實際執行方式取決於 queue connection;例如 sync 並不會讓工作交由另一個持久化佇列工作者處理。Laravel:Queued listeners、Queues

在 .NET 端,可以先分成這幾種:

機制 提供的能力 還需要確認
C# event/程序內通知 呼叫同一程序中的訂閱處理 沒有自動提供持久化與跨程序交付
async/await 以非同步方式等待工作完成 不代表建立背景佇列或換到獨立工作者
BackgroundService 與主機生命週期整合的背景執行 工作來源、保存、重試與復原策略
持久化工作佇列或訊息系統 依具體方案提供排程、交付與重試 重複處理、順序、提交時機與冪等性

C# 語言的 event 以委派機制通知訂閱者;它本身沒有保存訊息的功能。Microsoft:event keyword

例如訂單成功後寄信,如果需要在重啟後繼續處理,就要保存工作與必要參數。把 Task 丟出去後立刻回應,也要面對例外、請求範圍釋放與應用停止;可靠交付應使用明確的工作處理流程。

業務資料與發送意圖必須一起保存時,可以採用 Outbox;稍後發送、重複交付與接收端去重則有各自的責任。這些保證與使用哪個框架名稱無關,詳見前一篇的交易與 Outbox 比較。

十、Migration:EF 產生的是差異草稿,仍需要 review

Laravel 常見流程是手動建立 Migration,寫出 up/down 的結構變更。EF Core 採用 Migrations 時,則會比對目前 EF Model 與上一版 Snapshot,產生差異檔案;它不會只因為你改了 Entity,就自動修改正式資料庫。Laravel:Migrations、Microsoft:Migrations overview

產生的 Migration 仍然要 review。像欄位改名,工具可能產生刪除舊欄位、建立新欄位的操作,需要由開發者確認意圖並調整;資料搬移、回填與發布順序也要另行安排。Microsoft:Managing migrations

EF Core 也能從既有資料庫反向產生 Entity 與 Context。因此,團隊可以先決定 schema 由哪一套流程管理,再讓 EF 配合;採用 EF Core 並沒有要求所有專案都改成同一種 Code First 工作流。Microsoft:Reverse engineering

十一、把遷移當成重新確認責任的過程

如果要把既有 Laravel 後台移到 ASP.NET Core,我會從一個有代表性的列表開始,先把已知行為移過去:

  1. 固定可見資料範圍、排序、篩選與分頁的 API 契約。
  2. 把完整查詢放在 Handler 或 Query Service,在內部抽出共用查詢積木。
  3. 把跨請求共用狀態與每次操作狀態分開,明確配置 DI 生命週期。
  4. 逐一對應 Middleware 裡的責任:認證、授權、輸入驗證或業務規則。
  5. 盤點事件究竟在程序內執行,還是需要持久化、背景處理與重試。
  6. 最後依實際隔離需求決定 Repository、領域模型與其他抽象。

這樣做可以保留原本對後台需求的理解,同時找出哪些類別只是沿用舊名稱,哪些元件確實承擔了不可缺少的責任。

總結

從 Laravel 轉到 C#,共用查詢、隔離業務規則、管理請求管線的需求都還在。變化主要出現在元件分工、物件生命週期、查詢轉譯與背景工作的保證。

對一百個後台列表,可以共用分頁與排序機制,再由每個列表保有自己的查詢契約;對複雜業務,可以把規則放進明確的模型與政策;需要資料存取邊界時,再建立有清楚用途的 Repository。先確認責任與執行時機,熟悉的名詞才會成為可靠的遷移線索。

延伸閱讀

本文由 AI 協助整理與撰寫,討論起點為 Laravel 與 EF Core 概念轉換對話,技術敘述已另依文中官方與原始資料來源整理。封面為 AI 生成的概念插畫,以外形相似、內部構造不同的鎖具表達概念轉換,並非框架內部實作圖。